mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-01 06:11:42 -05:00
Compare commits
8 Commits
codex/depe
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f26278f16 | ||
|
|
b975e7fa2e | ||
|
|
6c3052f9e4 | ||
|
|
5bb8119ebf | ||
|
|
02411277d4 | ||
|
|
4cfb8c6d7b | ||
|
|
e74a51a5ff | ||
|
|
29cc6a10fe |
@@ -94,6 +94,12 @@ EMAIL_VERIFICATION_DISABLED=1
|
||||
# Password Reset. If you enable Password Reset functionality you have to setup SMTP-Settings, too.
|
||||
PASSWORD_RESET_DISABLED=1
|
||||
|
||||
# Password reset token lifetime in minutes. Must be between 5 and 120 if set.
|
||||
# PASSWORD_RESET_TOKEN_LIFETIME_MINUTES=30
|
||||
|
||||
# Development-only helper: log the password reset link to the server console instead of sending reset emails.
|
||||
# DEBUG_SHOW_RESET_LINK=1
|
||||
|
||||
# Email login. Disable the ability for users to login with email.
|
||||
# EMAIL_AUTH_DISABLED=1
|
||||
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -45,7 +45,7 @@ yarn-error.log*
|
||||
.direnv
|
||||
|
||||
# Playwright
|
||||
/test-results/
|
||||
**/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
|
||||
11
README.md
11
README.md
@@ -247,4 +247,15 @@ We currently do not offer Formbricks white-labeled. That means that we don't sel
|
||||
|
||||
The Enterprise Edition allows us to fund the development of Formbricks sustainably. It guarantees that the free and open-source surveying infrastructure we're building will be around for decades to come.
|
||||
|
||||
<a id="readme-de"></a>
|
||||
|
||||
## Deutsch
|
||||
|
||||
Formbricks ist eine freie, quelloffene und datenschutzorientierte Plattform für Surveys und Experience Management. Mit In-App-, Website-, Link- und E-Mail-Umfragen sammelt ihr Feedback entlang der gesamten User Journey.
|
||||
|
||||
- Website & Cloud: [formbricks.com](https://formbricks.com/) und [Cloud starten](https://app.formbricks.com/auth/signup)
|
||||
- Self-Hosting: [Deployment-Dokumentation](https://formbricks.com/docs/self-hosting/deployment)
|
||||
- Beitrag & Community: [Beitragen](https://formbricks.com/docs/developer-docs/contributing/get-started), [GitHub Discussions](https://github.com/formbricks/formbricks/discussions) und [Issues](https://github.com/formbricks/formbricks/issues)
|
||||
- Sicherheit & Lizenz: [`SECURITY.md`](./SECURITY.md) und [AGPLv3](https://github.com/formbricks/formbricks/blob/main/LICENSE)
|
||||
|
||||
<p align="right"><a href="#top">🔼 Back to top</a></p>
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
RocketIcon,
|
||||
UserCircleIcon,
|
||||
UserIcon,
|
||||
WorkflowIcon,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
@@ -116,13 +115,6 @@ 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`,
|
||||
@@ -130,7 +122,7 @@ export const MainNavigation = ({
|
||||
isActive: pathname?.includes("/workspace"),
|
||||
},
|
||||
],
|
||||
[t, environment.id, pathname, isFormbricksCloud]
|
||||
[t, environment.id, pathname]
|
||||
);
|
||||
|
||||
const dropdownNavigation = [
|
||||
|
||||
@@ -10,15 +10,16 @@ import {
|
||||
getIsEmailUnique,
|
||||
verifyUserPassword,
|
||||
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
|
||||
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
|
||||
import { EMAIL_VERIFICATION_DISABLED, PASSWORD_RESET_DISABLED } from "@/lib/constants";
|
||||
import { getUser, updateUser } from "@/lib/user/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import { requestPasswordReset } from "@/modules/auth/forgot-password/lib/password-reset-service";
|
||||
import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
|
||||
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email";
|
||||
import { sendVerificationNewEmail } from "@/modules/email";
|
||||
|
||||
function buildUserUpdatePayload(parsedInput: TUserPersonalInfoUpdateInput): TUserUpdateInput {
|
||||
return {
|
||||
@@ -85,11 +86,15 @@ export const updateUserAction = authenticatedActionClient.inputSchema(ZUserPerso
|
||||
|
||||
export const resetPasswordAction = authenticatedActionClient.action(
|
||||
withAuditLogging("passwordReset", "user", async ({ ctx }) => {
|
||||
if (PASSWORD_RESET_DISABLED) {
|
||||
throw new OperationNotAllowedError("Password reset is disabled");
|
||||
}
|
||||
|
||||
if (ctx.user.identityProvider !== "email") {
|
||||
throw new OperationNotAllowedError("Password reset is not allowed for this user.");
|
||||
}
|
||||
|
||||
await sendForgotPasswordEmail(ctx.user);
|
||||
await requestPasswordReset(ctx.user, "profile");
|
||||
|
||||
ctx.auditLoggingCtx.userId = ctx.user.id;
|
||||
|
||||
|
||||
@@ -3,13 +3,41 @@
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import type { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
|
||||
import { deleteOrganization, getOrganization, updateOrganization } from "@/lib/organization/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
|
||||
async function updateOrganizationAction<T extends z.ZodRawShape>({
|
||||
ctx,
|
||||
organizationId,
|
||||
schema,
|
||||
data,
|
||||
roles,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
organizationId: string;
|
||||
schema: z.ZodObject<T>;
|
||||
data: z.infer<z.ZodObject<T>>;
|
||||
roles: TOrganizationRole[];
|
||||
}) {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [{ type: "organization", schema, data, roles }],
|
||||
});
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
const oldObject = await getOrganization(organizationId);
|
||||
const result = await updateOrganization(organizationId, data);
|
||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||
ctx.auditLoggingCtx.newObject = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
const ZUpdateOrganizationNameAction = z.object({
|
||||
organizationId: ZId,
|
||||
data: ZOrganizationUpdateInput.pick({ name: true }),
|
||||
@@ -18,26 +46,55 @@ const ZUpdateOrganizationNameAction = z.object({
|
||||
export const updateOrganizationNameAction = authenticatedActionClient
|
||||
.inputSchema(ZUpdateOrganizationNameAction)
|
||||
.action(
|
||||
withAuditLogging("updated", "organization", async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
schema: ZOrganizationUpdateInput.pick({ name: true }),
|
||||
data: parsedInput.data,
|
||||
roles: ["owner"],
|
||||
},
|
||||
],
|
||||
});
|
||||
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
|
||||
const oldObject = await getOrganization(parsedInput.organizationId);
|
||||
const result = await updateOrganization(parsedInput.organizationId, parsedInput.data);
|
||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||
ctx.auditLoggingCtx.newObject = result;
|
||||
return result;
|
||||
})
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"organization",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZUpdateOrganizationNameAction>;
|
||||
}) =>
|
||||
updateOrganizationAction({
|
||||
ctx,
|
||||
organizationId: parsedInput.organizationId,
|
||||
schema: ZOrganizationUpdateInput.pick({ name: true }),
|
||||
data: parsedInput.data,
|
||||
roles: ["owner"],
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const ZUpdateOrganizationAISettingsAction = z.object({
|
||||
organizationId: ZId,
|
||||
data: ZOrganizationUpdateInput.pick({ isAISmartToolsEnabled: true, isAIDataAnalysisEnabled: true }),
|
||||
});
|
||||
|
||||
export const updateOrganizationAISettingsAction = authenticatedActionClient
|
||||
.inputSchema(ZUpdateOrganizationAISettingsAction)
|
||||
.action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"organization",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZUpdateOrganizationAISettingsAction>;
|
||||
}) =>
|
||||
updateOrganizationAction({
|
||||
ctx,
|
||||
organizationId: parsedInput.organizationId,
|
||||
schema: ZOrganizationUpdateInput.pick({
|
||||
isAISmartToolsEnabled: true,
|
||||
isAIDataAnalysisEnabled: true,
|
||||
}),
|
||||
data: parsedInput.data,
|
||||
roles: ["owner", "manager"],
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const ZDeleteOrganizationAction = z.object({
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { updateOrganizationAISettingsAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
|
||||
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||
|
||||
interface AISettingsToggleProps {
|
||||
organization: TOrganization;
|
||||
membershipRole?: TOrganizationRole;
|
||||
}
|
||||
|
||||
export const AISettingsToggle = ({ organization, membershipRole }: Readonly<AISettingsToggleProps>) => {
|
||||
const [loadingField, setLoadingField] = useState<string | null>(null);
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const { isOwner, isManager } = getAccessFlags(membershipRole);
|
||||
const canEdit = isOwner || isManager;
|
||||
|
||||
const handleToggle = async (
|
||||
field: "isAISmartToolsEnabled" | "isAIDataAnalysisEnabled",
|
||||
checked: boolean
|
||||
) => {
|
||||
setLoadingField(field);
|
||||
try {
|
||||
const response = await updateOrganizationAISettingsAction({
|
||||
organizationId: organization.id,
|
||||
data: { [field]: checked },
|
||||
});
|
||||
|
||||
if (response?.data) {
|
||||
toast.success(t("environments.settings.general.ai_settings_updated_successfully"));
|
||||
router.refresh();
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(response);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("common.something_went_wrong"));
|
||||
} finally {
|
||||
setLoadingField(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AdvancedOptionToggle
|
||||
isChecked={organization.isAISmartToolsEnabled}
|
||||
onToggle={(checked) => handleToggle("isAISmartToolsEnabled", checked)}
|
||||
htmlId="ai-smart-tools-toggle"
|
||||
title={t("environments.settings.general.ai_smart_tools_enabled")}
|
||||
description={t("environments.settings.general.ai_smart_tools_enabled_description")}
|
||||
disabled={loadingField !== null || !canEdit}
|
||||
customContainerClass="px-0"
|
||||
/>
|
||||
|
||||
<AdvancedOptionToggle
|
||||
isChecked={organization.isAIDataAnalysisEnabled}
|
||||
onToggle={(checked) => handleToggle("isAIDataAnalysisEnabled", checked)}
|
||||
htmlId="ai-data-analysis-toggle"
|
||||
title={t("environments.settings.general.ai_data_analysis_enabled")}
|
||||
description={t("environments.settings.general.ai_data_analysis_enabled_description")}
|
||||
disabled={loadingField !== null || !canEdit}
|
||||
customContainerClass="px-0"
|
||||
/>
|
||||
|
||||
{!canEdit && (
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>
|
||||
{t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -11,6 +11,7 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import packageJson from "@/package.json";
|
||||
import { SettingsCard } from "../../components/SettingsCard";
|
||||
import { AISettingsToggle } from "./components/AISettingsToggle";
|
||||
import { DeleteOrganization } from "./components/DeleteOrganization";
|
||||
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
|
||||
import { SecurityListTip } from "./components/SecurityListTip";
|
||||
@@ -60,6 +61,11 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
membershipRole={currentUserMembership?.role}
|
||||
/>
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
title={t("environments.settings.general.ai_enabled")}
|
||||
description={t("environments.settings.general.ai_enabled_description")}>
|
||||
<AISettingsToggle organization={organization} membershipRole={currentUserMembership?.role} />
|
||||
</SettingsCard>
|
||||
<EmailCustomizationSettings
|
||||
organization={organization}
|
||||
hasWhiteLabelPermission={hasWhiteLabelPermission}
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
"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="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-brand-light to-brand-dark 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="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:border-brand-dark focus:outline-none focus:ring-2 focus:ring-brand-light/20"
|
||||
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="h-6 w-6 text-brand-dark" />
|
||||
</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="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:border-brand-dark focus:bg-white focus:outline-none focus:ring-2 focus:ring-brand-light/20"
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -1,42 +0,0 @@
|
||||
import { Metadata } from "next";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getCloudBillingDisplayContext } from "@/modules/ee/billing/lib/cloud-billing-display";
|
||||
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");
|
||||
}
|
||||
|
||||
const cloudBillingDisplayContext = await getCloudBillingDisplayContext(organization.id);
|
||||
|
||||
return (
|
||||
<WorkflowsPage
|
||||
userEmail={user.email}
|
||||
organizationName={organization.name}
|
||||
billingPlan={cloudBillingDisplayContext.currentCloudPlan}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -76,7 +76,8 @@ const mockOrganization: TOrganization = {
|
||||
},
|
||||
usageCycleAnchor: new Date(),
|
||||
},
|
||||
isAIEnabled: false,
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
};
|
||||
|
||||
const mockSurveys: TSurvey[] = [
|
||||
|
||||
@@ -49,7 +49,8 @@ const mockOrganization: TOrganization = {
|
||||
},
|
||||
usageCycleAnchor: new Date(),
|
||||
},
|
||||
isAIEnabled: false,
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
};
|
||||
|
||||
const mockFollowUp: TSurveyCreateInputWithEnvironmentId["followUps"][number] = {
|
||||
|
||||
@@ -373,7 +373,6 @@ 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
|
||||
@@ -444,7 +443,6 @@ checksums:
|
||||
common/website_survey: 17513d25a07b6361768a15ec622b021b
|
||||
common/weeks: 545de30df4f44d3f6d1d344af6a10815
|
||||
common/welcome_card: 76081ebd5b2e35da9b0f080323704ae7
|
||||
common/workflows: b0c9c8615a9ba7d9cb73e767290a7f72
|
||||
common/workspace: b63ef0e99ee6f7fef6cbe4971ca6cf0f
|
||||
common/workspace_configuration: d0a5812d6a97d7724d565b1017c34387
|
||||
common/workspace_created_successfully: bf401ae83da954f1db48724e2a8e40f1
|
||||
@@ -1070,6 +1068,13 @@ checksums:
|
||||
environments/settings/enterprise/sso: 95e98e279bb89233d63549b202bd9112
|
||||
environments/settings/enterprise/teams: 21ab78abcba0f16c3029741563f789ea
|
||||
environments/settings/enterprise/unlock_the_full_power_of_formbricks_free_for_30_days: 104d07b63a42911c9673ceb08a4dbd43
|
||||
environments/settings/general/ai_data_analysis_enabled: 45fabb594da6851f73fef50ca40fe525
|
||||
environments/settings/general/ai_data_analysis_enabled_description: 46d4f0bdf4ebf89e78f79cc961a2de83
|
||||
environments/settings/general/ai_enabled: 3cb1fce89c525e754448d5bd143eb6b5
|
||||
environments/settings/general/ai_enabled_description: e8c3e9f362588898a6cea85e18c013a1
|
||||
environments/settings/general/ai_settings_updated_successfully: 2a6f534dc3a246ced46becd8a4a9543d
|
||||
environments/settings/general/ai_smart_tools_enabled: 1dda984f5262c5f9120ee9a409236758
|
||||
environments/settings/general/ai_smart_tools_enabled_description: 1ceca6707746d3ab4a530712a06d91da
|
||||
environments/settings/general/bulk_invite_warning_description: e8737a2fbd5ff353db5580d17b4b5a37
|
||||
environments/settings/general/cannot_delete_only_organization: 833cc6848b28f2694a4552b4de91a6ba
|
||||
environments/settings/general/cannot_leave_only_organization: dd8463262e4299fef7ad73512225c55b
|
||||
@@ -2459,8 +2464,8 @@ checksums:
|
||||
templates/csat_question_1_headline: bd4894e95695ce5bc9fc5d326c79bc90
|
||||
templates/csat_question_1_lower_label: 54d464343c0bc17231fd51aa2d73623f
|
||||
templates/csat_question_1_upper_label: 9f000f63949d875ae628fc354a2a7f6a
|
||||
templates/csat_question_2_choice_1: a0cf57bc571c95c43924a3c641d1355e
|
||||
templates/csat_question_2_choice_2: a3a49eb9cc86972bce6dc41a107f472d
|
||||
templates/csat_question_2_choice_1: 0cb1260dd25e94f56c2da7ab21b0e0ae
|
||||
templates/csat_question_2_choice_2: f12ed9d98c7965ab949efcc25f8ca85e
|
||||
templates/csat_question_2_choice_3: a7c58d9b8afdaefadeb1f5fdf4d5ad3f
|
||||
templates/csat_question_2_choice_4: d09723c4bc1d85d99c2a9248ed0d4578
|
||||
templates/csat_question_2_choice_5: a89ca2602a3322e89adf17b3349e03ab
|
||||
@@ -3182,14 +3187,3 @@ 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: ead918852c5840636a14baabfe94821e
|
||||
workflows/follow_up_placeholder: f680918bec28192282e229c3d4b5e80a
|
||||
workflows/generate_button: b194b6172a49af8374a19dd2cf39cfdc
|
||||
workflows/heading: a98a6b14d3e955f38cc16386df9a4111
|
||||
workflows/placeholder: f5d943582bf25e8734930844e598457b
|
||||
workflows/subheading: ebf5e3b3aeb85e13e843358cc5476f42
|
||||
workflows/submit_button: 7a062f2de02ce60b1d73e510ff1ca094
|
||||
workflows/thank_you_description: 7623c1ba4f059c8d9e68aae3360b20b1
|
||||
workflows/thank_you_title: 07edd8c50685a52c0969d711df26d768
|
||||
|
||||
@@ -27,7 +27,9 @@ export const IMPRINT_URL = env.IMPRINT_URL;
|
||||
export const IMPRINT_ADDRESS = env.IMPRINT_ADDRESS;
|
||||
|
||||
export const DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS = env.DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS === "1";
|
||||
export const DEBUG_SHOW_RESET_LINK = !IS_PRODUCTION && env.DEBUG_SHOW_RESET_LINK === "1";
|
||||
export const PASSWORD_RESET_DISABLED = env.PASSWORD_RESET_DISABLED === "1";
|
||||
export const PASSWORD_RESET_TOKEN_LIFETIME_MINUTES = env.PASSWORD_RESET_TOKEN_LIFETIME_MINUTES;
|
||||
export const EMAIL_VERIFICATION_DISABLED = env.EMAIL_VERIFICATION_DISABLED === "1";
|
||||
|
||||
export const GOOGLE_OAUTH_ENABLED = !!(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET);
|
||||
|
||||
77
apps/web/lib/env.test.ts
Normal file
77
apps/web/lib/env.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const ORIGINAL_ENV = process.env;
|
||||
|
||||
const setTestEnv = (overrides: Record<string, string | undefined> = {}) => {
|
||||
process.env = {
|
||||
...ORIGINAL_ENV,
|
||||
NODE_ENV: "test",
|
||||
DATABASE_URL: "https://example.com/db",
|
||||
ENCRYPTION_KEY: "12345678901234567890123456789012",
|
||||
...overrides,
|
||||
};
|
||||
};
|
||||
|
||||
describe("env", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = ORIGINAL_ENV;
|
||||
});
|
||||
|
||||
test("uses the default password reset token lifetime when env var is not set", async () => {
|
||||
setTestEnv({
|
||||
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: undefined,
|
||||
});
|
||||
|
||||
const { env } = await import("./env");
|
||||
|
||||
expect(env.PASSWORD_RESET_TOKEN_LIFETIME_MINUTES).toBe(30);
|
||||
});
|
||||
|
||||
test("uses the configured password reset token lifetime", async () => {
|
||||
setTestEnv({
|
||||
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: "45",
|
||||
});
|
||||
|
||||
const { env } = await import("./env");
|
||||
|
||||
expect(env.PASSWORD_RESET_TOKEN_LIFETIME_MINUTES).toBe(45);
|
||||
});
|
||||
|
||||
test("fails to load when the password reset token lifetime is not an integer", async () => {
|
||||
setTestEnv({
|
||||
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: "30minutes",
|
||||
});
|
||||
|
||||
await expect(import("./env")).rejects.toThrow("Invalid environment variables");
|
||||
});
|
||||
|
||||
test("fails to load when the password reset token lifetime is out of range", async () => {
|
||||
setTestEnv({
|
||||
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: "121",
|
||||
});
|
||||
|
||||
await expect(import("./env")).rejects.toThrow("Invalid environment variables");
|
||||
});
|
||||
|
||||
test("allows enabling DEBUG_SHOW_RESET_LINK", async () => {
|
||||
setTestEnv({
|
||||
DEBUG_SHOW_RESET_LINK: "1",
|
||||
});
|
||||
|
||||
const { env } = await import("./env");
|
||||
|
||||
expect(env.DEBUG_SHOW_RESET_LINK).toBe("1");
|
||||
});
|
||||
|
||||
test("fails to load when DEBUG_SHOW_RESET_LINK is invalid", async () => {
|
||||
setTestEnv({
|
||||
DEBUG_SHOW_RESET_LINK: "true",
|
||||
});
|
||||
|
||||
await expect(import("./env")).rejects.toThrow("Invalid environment variables");
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ export const env = createEnv({
|
||||
DATABASE_URL: z.url(),
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: z.enum(["1", "0"]).optional(),
|
||||
DEBUG: z.enum(["1", "0"]).optional(),
|
||||
DEBUG_SHOW_RESET_LINK: z.enum(["1", "0"]).optional(),
|
||||
AUTH_DEFAULT_TEAM_ID: z.string().optional(),
|
||||
AUTH_SKIP_INVITE_FOR_SSO: z.enum(["1", "0"]).optional(),
|
||||
E2E_TESTING: z.enum(["1", "0"]).optional(),
|
||||
@@ -61,6 +62,7 @@ export const env = createEnv({
|
||||
? z.string().optional()
|
||||
: z.url("REDIS_URL is required for caching, rate limiting, and audit logging"),
|
||||
PASSWORD_RESET_DISABLED: z.enum(["1", "0"]).optional(),
|
||||
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: z.coerce.number().int().min(5).max(120).optional().default(30),
|
||||
PRIVACY_URL: z
|
||||
.url()
|
||||
.optional()
|
||||
@@ -144,6 +146,7 @@ export const env = createEnv({
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: process.env.DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS,
|
||||
DEBUG: process.env.DEBUG,
|
||||
DEBUG_SHOW_RESET_LINK: process.env.DEBUG_SHOW_RESET_LINK,
|
||||
AUTH_DEFAULT_TEAM_ID: process.env.AUTH_SSO_DEFAULT_TEAM_ID,
|
||||
AUTH_SKIP_INVITE_FOR_SSO: process.env.AUTH_SKIP_INVITE_FOR_SSO,
|
||||
E2E_TESTING: process.env.E2E_TESTING,
|
||||
@@ -183,6 +186,7 @@ export const env = createEnv({
|
||||
OIDC_SIGNING_ALGORITHM: process.env.OIDC_SIGNING_ALGORITHM,
|
||||
REDIS_URL: process.env.REDIS_URL,
|
||||
PASSWORD_RESET_DISABLED: process.env.PASSWORD_RESET_DISABLED,
|
||||
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: process.env.PASSWORD_RESET_TOKEN_LIFETIME_MINUTES,
|
||||
PRIVACY_URL: process.env.PRIVACY_URL,
|
||||
RATE_LIMITING_DISABLED: process.env.RATE_LIMITING_DISABLED,
|
||||
S3_ACCESS_KEY: process.env.S3_ACCESS_KEY,
|
||||
|
||||
@@ -84,7 +84,9 @@ export const extractLanguageIds = (languages: TLanguage[]): string[] => {
|
||||
|
||||
export const getLanguageCode = (surveyLanguages: TSurveyLanguage[], languageCode: string | null) => {
|
||||
if (!surveyLanguages?.length || !languageCode) return "default";
|
||||
const language = surveyLanguages.find((surveyLanguage) => surveyLanguage.language.code === languageCode);
|
||||
const language = surveyLanguages.find(
|
||||
(surveyLanguage) => surveyLanguage.language.code.toLowerCase() === languageCode.toLowerCase()
|
||||
);
|
||||
return language?.default ? "default" : language?.language.code || "default";
|
||||
};
|
||||
|
||||
|
||||
@@ -37,7 +37,8 @@ describe("auth", () => {
|
||||
},
|
||||
usageCycleAnchor: new Date(),
|
||||
},
|
||||
isAIEnabled: false,
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
},
|
||||
];
|
||||
vi.mocked(getOrganizationsByUserId).mockResolvedValue(mockOrganizations);
|
||||
|
||||
@@ -72,7 +72,8 @@ describe("Organization Service", () => {
|
||||
stripeCustomerId: null,
|
||||
usageCycleAnchor: new Date(),
|
||||
},
|
||||
isAIEnabled: false,
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
whitelabel: false,
|
||||
};
|
||||
|
||||
@@ -124,7 +125,8 @@ describe("Organization Service", () => {
|
||||
stripeCustomerId: null,
|
||||
usageCycleAnchor: new Date(),
|
||||
},
|
||||
isAIEnabled: false,
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
whitelabel: false,
|
||||
},
|
||||
];
|
||||
@@ -176,7 +178,8 @@ describe("Organization Service", () => {
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
billing: expectedBilling,
|
||||
isAIEnabled: false,
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
whitelabel: false,
|
||||
};
|
||||
|
||||
@@ -235,7 +238,8 @@ describe("Organization Service", () => {
|
||||
stripeCustomerId: null,
|
||||
usageCycleAnchor: new Date(),
|
||||
},
|
||||
isAIEnabled: false,
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
whitelabel: false,
|
||||
memberships: [{ userId: "user1" }, { userId: "user2" }],
|
||||
projects: [
|
||||
@@ -276,7 +280,8 @@ describe("Organization Service", () => {
|
||||
stripeCustomerId: null,
|
||||
usageCycleAnchor: expect.any(Date),
|
||||
},
|
||||
isAIEnabled: false,
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
whitelabel: false,
|
||||
});
|
||||
expect(prisma.organization.update).toHaveBeenCalledWith({
|
||||
|
||||
@@ -34,7 +34,8 @@ export const select = {
|
||||
stripe: true,
|
||||
},
|
||||
},
|
||||
isAIEnabled: true,
|
||||
isAISmartToolsEnabled: true,
|
||||
isAIDataAnalysisEnabled: true,
|
||||
whitelabel: true,
|
||||
} satisfies Prisma.OrganizationSelect;
|
||||
|
||||
@@ -72,7 +73,8 @@ const mapOrganization = (organization: TOrganizationWithBilling): TOrganization
|
||||
updatedAt: organization.updatedAt,
|
||||
name: organization.name,
|
||||
billing: mapOrganizationBilling(organization.billing),
|
||||
isAIEnabled: organization.isAIEnabled,
|
||||
isAISmartToolsEnabled: organization.isAISmartToolsEnabled,
|
||||
isAIDataAnalysisEnabled: organization.isAIDataAnalysisEnabled,
|
||||
whitelabel: organization.whitelabel as TOrganization["whitelabel"],
|
||||
});
|
||||
|
||||
|
||||
@@ -232,7 +232,8 @@ export const mockOrganizationOutput: TOrganization = {
|
||||
name: "mock Organization",
|
||||
createdAt: currentDate,
|
||||
updatedAt: currentDate,
|
||||
isAIEnabled: false,
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
billing: {
|
||||
stripeCustomerId: null,
|
||||
limits: {
|
||||
|
||||
@@ -70,7 +70,8 @@ describe("User Service", () => {
|
||||
},
|
||||
usageCycleAnchor: new Date(),
|
||||
},
|
||||
isAIEnabled: false,
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
},
|
||||
{
|
||||
id: "org2",
|
||||
@@ -87,7 +88,8 @@ describe("User Service", () => {
|
||||
},
|
||||
usageCycleAnchor: new Date(),
|
||||
},
|
||||
isAIEnabled: false,
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
AuthenticationError,
|
||||
AuthorizationError,
|
||||
EXPECTED_ERROR_NAMES,
|
||||
INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE,
|
||||
InvalidInputError,
|
||||
InvalidPasswordResetTokenError,
|
||||
OperationNotAllowedError,
|
||||
ResourceNotFoundError,
|
||||
TooManyRequestsError,
|
||||
@@ -71,6 +73,7 @@ describe("isExpectedError (shared helper)", () => {
|
||||
"AuthenticationError",
|
||||
"OperationNotAllowedError",
|
||||
"TooManyRequestsError",
|
||||
"InvalidPasswordResetTokenError",
|
||||
];
|
||||
|
||||
expect(EXPECTED_ERROR_NAMES.size).toBe(expected.length);
|
||||
@@ -87,6 +90,7 @@ describe("isExpectedError (shared helper)", () => {
|
||||
{ ErrorClass: InvalidInputError, args: ["Invalid input"] },
|
||||
{ ErrorClass: ValidationError, args: ["Invalid data"] },
|
||||
{ ErrorClass: OperationNotAllowedError, args: ["Not allowed"] },
|
||||
{ ErrorClass: InvalidPasswordResetTokenError, args: [INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE] },
|
||||
])("returns true for $ErrorClass.name", ({ ErrorClass, args }) => {
|
||||
const error = new (ErrorClass as any)(...args);
|
||||
expect(isExpectedError(error)).toBe(true);
|
||||
@@ -174,6 +178,14 @@ describe("actionClient handleServerError", () => {
|
||||
expect(result?.serverError).toBe("Not allowed");
|
||||
expect(Sentry.captureException).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("InvalidPasswordResetTokenError returns its message and is not sent to Sentry", async () => {
|
||||
const result = await executeThrowingAction(
|
||||
new InvalidPasswordResetTokenError(INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE)
|
||||
);
|
||||
expect(result?.serverError).toBe(INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE);
|
||||
expect(Sentry.captureException).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("unexpected errors SHOULD be reported to Sentry", () => {
|
||||
|
||||
@@ -400,7 +400,6 @@
|
||||
"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",
|
||||
@@ -471,7 +470,6 @@
|
||||
"website_survey": "Website-Umfrage",
|
||||
"weeks": "Wochen",
|
||||
"welcome_card": "Willkommenskarte",
|
||||
"workflows": "Workflows",
|
||||
"workspace": "Arbeitsbereich",
|
||||
"workspace_configuration": "Projektkonfiguration",
|
||||
"workspace_created_successfully": "Projekt erfolgreich erstellt",
|
||||
@@ -507,7 +505,7 @@
|
||||
"forgot_password_email_change_password": "Passwort ändern",
|
||||
"forgot_password_email_did_not_request": "Wenn Du sie nicht angefordert hast, ignoriere bitte diese E-Mail.",
|
||||
"forgot_password_email_heading": "Passwort ändern",
|
||||
"forgot_password_email_link_valid_for_24_hours": "Der Link ist 24 Stunden gültig.",
|
||||
"forgot_password_email_link_valid_for_24_hours": "Der Link ist {minutes} Minuten gültig.",
|
||||
"forgot_password_email_subject": "Setz dein Formbricks-Passwort zurück",
|
||||
"forgot_password_email_text": "Du hast einen Link angefordert, um dein Passwort zu ändern. Du kannst dies tun, indem Du auf den untenstehenden Link klickst:",
|
||||
"hidden_field": "Verstecktes Feld",
|
||||
@@ -1131,6 +1129,13 @@
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "Schalte die volle Power von Formbricks frei. 30 Tage kostenlos."
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Datenanreicherung & Analyse (KI)",
|
||||
"ai_data_analysis_enabled_description": "KI, um mehr aus deinen Daten herauszuholen, Dashboards, Diagramme, Berichte und mehr einzurichten. Greift auf deine Erfahrungsdaten zu.",
|
||||
"ai_enabled": "Formbricks AI",
|
||||
"ai_enabled_description": "Verwalte KI-gestützte Funktionen für diese Organisation.",
|
||||
"ai_settings_updated_successfully": "KI-Einstellungen erfolgreich aktualisiert",
|
||||
"ai_smart_tools_enabled": "Intelligente Funktionen (KI)",
|
||||
"ai_smart_tools_enabled_description": "KI, um dir zu helfen, in kürzerer Zeit mehr zu erreichen. Greift niemals auf mit Formbricks gesammelte Daten zu. Wird nur verwendet, um z. B. Umfragen in andere Sprachen zu übersetzen.",
|
||||
"bulk_invite_warning_description": "Bitte beachte, dass im Free-Plan alle Organisationsmitglieder automatisch die Rolle \"Owner\" zugewiesen bekommen, unabhängig von der im CSV-File angegebenen Rolle.",
|
||||
"cannot_delete_only_organization": "Das ist deine einzige Organisation, sie kann nicht gelöscht werden. Erstelle zuerst eine neue Organisation.",
|
||||
"cannot_leave_only_organization": "Du kannst diese Organisation nicht verlassen, da es deine einzige Organisation ist. Erstelle zuerst eine neue Organisation.",
|
||||
@@ -2614,8 +2619,8 @@
|
||||
"csat_question_1_headline": "Wie wahrscheinlich ist es, dass Du dieses $[projectName] einem Freund oder Kollegen empfehlen würdest?",
|
||||
"csat_question_1_lower_label": "Nicht wahrscheinlich",
|
||||
"csat_question_1_upper_label": "Sehr wahrscheinlich",
|
||||
"csat_question_2_choice_1": "Etwas zufrieden",
|
||||
"csat_question_2_choice_2": "Sehr zufrieden",
|
||||
"csat_question_2_choice_1": "Sehr zufrieden",
|
||||
"csat_question_2_choice_2": "Etwas zufrieden",
|
||||
"csat_question_2_choice_3": "Weder zufrieden noch unzufrieden",
|
||||
"csat_question_2_choice_4": "Etwas unzufrieden",
|
||||
"csat_question_2_choice_5": "Sehr unzufrieden",
|
||||
@@ -3337,18 +3342,5 @@
|
||||
"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": "Möchten Sie noch etwas hinzufügen?",
|
||||
"follow_up_placeholder": "Welche konkreten Aufgaben möchten Sie automatisieren? Gibt es Tools oder Integrationen, die Sie einbinden möchten?",
|
||||
"generate_button": "Workflow generieren",
|
||||
"heading": "Welchen Workflow möchtest du erstellen?",
|
||||
"placeholder": "Beschreiben Sie den Workflow, den Sie erstellen möchten…",
|
||||
"subheading": "Generiere deinen Workflow in Sekunden.",
|
||||
"submit_button": "Details hinzufügen",
|
||||
"thank_you_description": "Ihr Feedback hilft uns, die Workflows-Funktion so zu gestalten, wie Sie sie brauchen. Wir halten Sie über unseren Fortschritt auf dem Laufenden.",
|
||||
"thank_you_title": "Danke für dein Feedback!"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,7 +400,6 @@
|
||||
"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",
|
||||
@@ -471,7 +470,6 @@
|
||||
"website_survey": "Website Survey",
|
||||
"weeks": "weeks",
|
||||
"welcome_card": "Welcome card",
|
||||
"workflows": "Workflows",
|
||||
"workspace": "Workspace",
|
||||
"workspace_configuration": "Workspace Configuration",
|
||||
"workspace_created_successfully": "Workspace created successfully",
|
||||
@@ -507,7 +505,7 @@
|
||||
"forgot_password_email_change_password": "Change password",
|
||||
"forgot_password_email_did_not_request": "If you did not request this, please ignore this email.",
|
||||
"forgot_password_email_heading": "Change password",
|
||||
"forgot_password_email_link_valid_for_24_hours": "The link is valid for 24 hours.",
|
||||
"forgot_password_email_link_valid_for_24_hours": "The link is valid for {minutes} minutes.",
|
||||
"forgot_password_email_subject": "Reset your Formbricks password",
|
||||
"forgot_password_email_text": "You have requested a link to change your password. You can do this by clicking the link below:",
|
||||
"hidden_field": "Hidden field",
|
||||
@@ -1131,6 +1129,13 @@
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "Unlock the full power of Formbricks. Free for 30 days."
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Data enrichment & analysis (AI)",
|
||||
"ai_data_analysis_enabled_description": "AI to get more out of your data, setup dashboards, charts, reports and more. Touches your experience data.",
|
||||
"ai_enabled": "Formbricks AI",
|
||||
"ai_enabled_description": "Manage AI-powered features for this organization.",
|
||||
"ai_settings_updated_successfully": "AI settings updated successfully",
|
||||
"ai_smart_tools_enabled": "Smart functionality (AI)",
|
||||
"ai_smart_tools_enabled_description": "AI to help you achieve more in less time. Never touches data collected with Formbricks. Only used to e.g. translate surveys to other languages.",
|
||||
"bulk_invite_warning_description": "On the free plan, all organization members are always assigned the “Owner” role.",
|
||||
"cannot_delete_only_organization": "This is your only organization, it cannot be deleted. Create a new organization first.",
|
||||
"cannot_leave_only_organization": "You cannot leave this organization as it is your only organization. Create a new organization first.",
|
||||
@@ -2614,8 +2619,8 @@
|
||||
"csat_question_1_headline": "How likely is it that you would recommend this $[projectName] to a friend or colleague?",
|
||||
"csat_question_1_lower_label": "Not likely",
|
||||
"csat_question_1_upper_label": "Very likely",
|
||||
"csat_question_2_choice_1": "Somewhat satisfied",
|
||||
"csat_question_2_choice_2": "Very satisfied",
|
||||
"csat_question_2_choice_1": "Very satisfied",
|
||||
"csat_question_2_choice_2": "Somewhat satisfied",
|
||||
"csat_question_2_choice_3": "Neither satisfied nor dissatisfied",
|
||||
"csat_question_2_choice_4": "Somewhat dissatisfied",
|
||||
"csat_question_2_choice_5": "Very dissatisfied",
|
||||
@@ -3337,18 +3342,5 @@
|
||||
"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 would like to add?",
|
||||
"follow_up_placeholder": "What specific tasks would you like to automate? Any tools or integrations you would 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 will keep you posted on our progress.",
|
||||
"thank_you_title": "Thank you for your feedback!"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,7 +400,6 @@
|
||||
"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",
|
||||
@@ -471,7 +470,6 @@
|
||||
"website_survey": "Encuesta de sitio web",
|
||||
"weeks": "semanas",
|
||||
"welcome_card": "Tarjeta de bienvenida",
|
||||
"workflows": "Flujos de trabajo",
|
||||
"workspace": "Espacio de trabajo",
|
||||
"workspace_configuration": "Configuración del proyecto",
|
||||
"workspace_created_successfully": "Proyecto creado correctamente",
|
||||
@@ -507,7 +505,7 @@
|
||||
"forgot_password_email_change_password": "Cambiar contraseña",
|
||||
"forgot_password_email_did_not_request": "Si no has solicitado esto, por favor ignora este correo electrónico.",
|
||||
"forgot_password_email_heading": "Cambiar contraseña",
|
||||
"forgot_password_email_link_valid_for_24_hours": "El enlace es válido durante 24 horas.",
|
||||
"forgot_password_email_link_valid_for_24_hours": "El enlace es válido durante {minutes} minutos.",
|
||||
"forgot_password_email_subject": "Restablece tu contraseña de Formbricks",
|
||||
"forgot_password_email_text": "Has solicitado un enlace para cambiar tu contraseña. Puedes hacerlo haciendo clic en el enlace a continuación:",
|
||||
"hidden_field": "Campo oculto",
|
||||
@@ -1131,6 +1129,13 @@
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "Desbloquea todo el potencial de Formbricks. Gratis durante 30 días."
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Enriquecimiento y análisis de datos (IA)",
|
||||
"ai_data_analysis_enabled_description": "IA para sacar más partido a tus datos, configurar paneles, gráficos, informes y más. Accede a los datos de experiencia.",
|
||||
"ai_enabled": "IA de Formbricks",
|
||||
"ai_enabled_description": "Gestiona las funciones impulsadas por IA para esta organización.",
|
||||
"ai_settings_updated_successfully": "Configuración de IA actualizada correctamente",
|
||||
"ai_smart_tools_enabled": "Funcionalidad inteligente (IA)",
|
||||
"ai_smart_tools_enabled_description": "IA para ayudarte a conseguir más en menos tiempo. Nunca accede a los datos recopilados con Formbricks. Solo se usa para, por ejemplo, traducir encuestas a otros idiomas.",
|
||||
"bulk_invite_warning_description": "En el plan gratuito, a todos los miembros de la organización se les asigna siempre el rol de \"Propietario\".",
|
||||
"cannot_delete_only_organization": "Esta es tu única organización, no se puede eliminar. Crea una nueva organización primero.",
|
||||
"cannot_leave_only_organization": "No puedes abandonar esta organización ya que es tu única organización. Crea una nueva organización primero.",
|
||||
@@ -2614,8 +2619,8 @@
|
||||
"csat_question_1_headline": "¿Qué probabilidad hay de que recomiendes este $[projectName] a un amigo o colega?",
|
||||
"csat_question_1_lower_label": "Poco probable",
|
||||
"csat_question_1_upper_label": "Muy probable",
|
||||
"csat_question_2_choice_1": "Algo satisfecho",
|
||||
"csat_question_2_choice_2": "Muy satisfecho",
|
||||
"csat_question_2_choice_1": "Muy satisfecho",
|
||||
"csat_question_2_choice_2": "Algo satisfecho",
|
||||
"csat_question_2_choice_3": "Ni satisfecho ni insatisfecho",
|
||||
"csat_question_2_choice_4": "Algo insatisfecho",
|
||||
"csat_question_2_choice_5": "Muy insatisfecho",
|
||||
@@ -3337,18 +3342,5 @@
|
||||
"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 quieras 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!"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,7 +400,6 @@
|
||||
"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",
|
||||
@@ -471,7 +470,6 @@
|
||||
"website_survey": "Sondage de site web",
|
||||
"weeks": "semaines",
|
||||
"welcome_card": "Carte de bienvenue",
|
||||
"workflows": "Workflows",
|
||||
"workspace": "Espace de travail",
|
||||
"workspace_configuration": "Configuration du projet",
|
||||
"workspace_created_successfully": "Projet créé avec succès",
|
||||
@@ -507,7 +505,7 @@
|
||||
"forgot_password_email_change_password": "Changer le mot de passe",
|
||||
"forgot_password_email_did_not_request": "Si vous n'avez pas demandé cela, veuillez ignorer cet e-mail.",
|
||||
"forgot_password_email_heading": "Changer le mot de passe",
|
||||
"forgot_password_email_link_valid_for_24_hours": "Le lien est valable pendant 24 heures.",
|
||||
"forgot_password_email_link_valid_for_24_hours": "Le lien est valable pendant {minutes} minutes.",
|
||||
"forgot_password_email_subject": "Réinitialise ton mot de passe Formbricks",
|
||||
"forgot_password_email_text": "Vous avez demandé un lien pour changer votre mot de passe. Vous pouvez le faire en cliquant sur le lien ci-dessous :",
|
||||
"hidden_field": "Champ caché",
|
||||
@@ -1131,6 +1129,13 @@
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "Débloquez tout le potentiel de Formbricks. Gratuit pendant 30 jours."
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Enrichissement et analyse des données (IA)",
|
||||
"ai_data_analysis_enabled_description": "L'IA pour tirer le meilleur parti de vos données, configurer des tableaux de bord, des graphiques, des rapports et plus encore. Accède à vos données d'expérience.",
|
||||
"ai_enabled": "IA Formbricks",
|
||||
"ai_enabled_description": "Gérer les fonctionnalités alimentées par l'IA pour cette organisation.",
|
||||
"ai_settings_updated_successfully": "Paramètres IA mis à jour avec succès",
|
||||
"ai_smart_tools_enabled": "Fonctionnalités intelligentes (IA)",
|
||||
"ai_smart_tools_enabled_description": "L'IA pour vous aider à accomplir plus en moins de temps. N'accède jamais aux données collectées avec Formbricks. Utilisée uniquement pour, par exemple, traduire les sondages dans d'autres langues.",
|
||||
"bulk_invite_warning_description": "Dans le plan gratuit, tous les membres de l'organisation se voient toujours attribuer le rôle \"Owner\".",
|
||||
"cannot_delete_only_organization": "C'est votre seule organisation, elle ne peut pas être supprimée. Créez d'abord une nouvelle organisation.",
|
||||
"cannot_leave_only_organization": "Vous ne pouvez pas quitter cette organisation car c'est votre seule organisation. Créez d'abord une nouvelle organisation.",
|
||||
@@ -2614,8 +2619,8 @@
|
||||
"csat_question_1_headline": "Quelle est la probabilité que vous recommandiez ce $[projectName] à un ami ou un collègue ?",
|
||||
"csat_question_1_lower_label": "Peu probable",
|
||||
"csat_question_1_upper_label": "Très probable",
|
||||
"csat_question_2_choice_1": "Un peu satisfait",
|
||||
"csat_question_2_choice_2": "Très satisfait",
|
||||
"csat_question_2_choice_1": "Très satisfait",
|
||||
"csat_question_2_choice_2": "Un peu satisfait",
|
||||
"csat_question_2_choice_3": "Ni satisfait ni insatisfait",
|
||||
"csat_question_2_choice_4": "Un peu insatisfait",
|
||||
"csat_question_2_choice_5": "Très insatisfait",
|
||||
@@ -3337,18 +3342,5 @@
|
||||
"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": "Souhaitez-vous ajouter quelque chose ?",
|
||||
"follow_up_placeholder": "Quelles tâches spécifiques souhaitez-vous automatiser ? Y a-t-il des outils ou intégrations que vous aimeriez inclure ?",
|
||||
"generate_button": "Générer le workflow",
|
||||
"heading": "Quel workflow souhaitez-vous créer ?",
|
||||
"placeholder": "Décrivez le processus que vous souhaitez générer…",
|
||||
"subheading": "Générez votre workflow en quelques secondes.",
|
||||
"submit_button": "Ajouter des détails",
|
||||
"thank_you_description": "Vos retours nous aident à construire la fonctionnalité Workflows dont vous avez réellement besoin. Nous vous tiendrons informé(e) de notre avancement.",
|
||||
"thank_you_title": "Merci pour vos retours !"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,7 +400,6 @@
|
||||
"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",
|
||||
@@ -471,7 +470,6 @@
|
||||
"website_survey": "Webhely kérdőív",
|
||||
"weeks": "hét",
|
||||
"welcome_card": "Üdvözlő kártya",
|
||||
"workflows": "Munkafolyamatok",
|
||||
"workspace": "Munkaterület",
|
||||
"workspace_configuration": "Munkaterület beállítása",
|
||||
"workspace_created_successfully": "A munkaterület sikeresen létrehozva",
|
||||
@@ -507,7 +505,7 @@
|
||||
"forgot_password_email_change_password": "Jelszó megváltoztatása",
|
||||
"forgot_password_email_did_not_request": "Ha Ön nem kérte ezt, akkor hagyja figyelmen kívül ezt a levelet.",
|
||||
"forgot_password_email_heading": "Jelszó megváltoztatása",
|
||||
"forgot_password_email_link_valid_for_24_hours": "A hivatkozás 24 órán keresztül érvényes.",
|
||||
"forgot_password_email_link_valid_for_24_hours": "A hivatkozás {minutes} percig érvényes.",
|
||||
"forgot_password_email_subject": "A Formbricks-jelszó visszaállítása",
|
||||
"forgot_password_email_text": "Hivatkozást kért a jelszava megváltoztatásához. Ezt a lenti hivatkozásra kattintva teheti meg:",
|
||||
"hidden_field": "Rejtett mező",
|
||||
@@ -1131,6 +1129,13 @@
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "A Formbricks teljes erejének feloldása. 30 napig ingyen."
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Adatgazdagítás és elemzés (AI)",
|
||||
"ai_data_analysis_enabled_description": "AI segítségével többet hozhat ki az adataiból, irányítópultokat, diagramokat, jelentéseket és egyebeket állíthat be. Hozzáfér az élményekhez kapcsolódó adatokhoz.",
|
||||
"ai_enabled": "Formbricks AI",
|
||||
"ai_enabled_description": "AI-alapú funkciók kezelése ehhez a szervezethez.",
|
||||
"ai_settings_updated_successfully": "AI beállítások sikeresen frissítve",
|
||||
"ai_smart_tools_enabled": "Intelligens funkciók (AI)",
|
||||
"ai_smart_tools_enabled_description": "AI segítségével kevesebb idő alatt többet érhet el. Soha nem fér hozzá a Formbricks által gyűjtött adatokhoz. Csak például felmérések más nyelvekre történő fordításához használatos.",
|
||||
"bulk_invite_warning_description": "Az ingyenes csomagban az összes szervezeti tag mindig a „Tulajdonos” szerephez van hozzárendelve.",
|
||||
"cannot_delete_only_organization": "Ez az egyetlen szervezete, nem lehet törölni. Először hozzon létre egy új szervezetet.",
|
||||
"cannot_leave_only_organization": "Nem hagyhatja el ezt a szervezetet, mivel ez az egyetlen szervezete. Először hozzon létre egy új szervezetet.",
|
||||
@@ -2614,8 +2619,8 @@
|
||||
"csat_question_1_headline": "Mennyire valószínű, hogy ezt a(z) $[projectName] projektet ajánlaná egy ismerősnek vagy kollégának?",
|
||||
"csat_question_1_lower_label": "Nem valószínű",
|
||||
"csat_question_1_upper_label": "Nagyon valószínű",
|
||||
"csat_question_2_choice_1": "Valamelyest elégedett",
|
||||
"csat_question_2_choice_2": "Nagyon elégedett",
|
||||
"csat_question_2_choice_1": "Nagyon elégedett",
|
||||
"csat_question_2_choice_2": "Valamelyest elégedett",
|
||||
"csat_question_2_choice_3": "Sem elégedett, sem elégedetlen",
|
||||
"csat_question_2_choice_4": "Valamelyest elégedetlen",
|
||||
"csat_question_2_choice_5": "Nagyon elégedetlen",
|
||||
@@ -3337,18 +3342,5 @@
|
||||
"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 munkafolyamatra vonatkozó ötletét! Jelenleg a funkció kialakításán dolgozunk, és a visszajelzése segít nekünk abban, hogy pontosan azt alkossuk meg, amire szüksége van.",
|
||||
"coming_soon_title": "Már majdnem kész vagyunk!",
|
||||
"follow_up_label": "Van még bármi egyéb, amit hozzá szeretne fűzni?",
|
||||
"follow_up_placeholder": "Milyen konkrét feladatokat szeretne automatizálni? Vannak olyan eszközök vagy integrációk, amelyeket szívesen látna a rendszerben?",
|
||||
"generate_button": "Munkafolyamat előállítása",
|
||||
"heading": "Milyen munkafolyamatot szeretne létrehozni?",
|
||||
"placeholder": "Mutassa be az előállítani kívánt munkafolyamatot…",
|
||||
"subheading": "Munkafolyamat előállítása másodpercek alatt.",
|
||||
"submit_button": "Részletek hozzáadása",
|
||||
"thank_you_description": "A visszajelzése segít nekünk abban, hogy olyan Munkafolyamatok funkciót alakítsunk ki, amelyre valóban szüksége van. Folyamatosan tájékoztatni fogjuk Önt a fejlesztés előrehaladásáról.",
|
||||
"thank_you_title": "Köszönjük a visszajelzését!"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,7 +400,6 @@
|
||||
"show_response_count": "回答数を表示",
|
||||
"shown": "表示済み",
|
||||
"size": "サイズ",
|
||||
"skip": "スキップ",
|
||||
"skipped": "スキップ済み",
|
||||
"skips": "スキップ数",
|
||||
"some_files_failed_to_upload": "一部のファイルのアップロードに失敗しました",
|
||||
@@ -471,7 +470,6 @@
|
||||
"website_survey": "ウェブサイトフォーム",
|
||||
"weeks": "週間",
|
||||
"welcome_card": "ウェルカムカード",
|
||||
"workflows": "ワークフロー",
|
||||
"workspace": "ワークスペース",
|
||||
"workspace_configuration": "ワークスペース設定",
|
||||
"workspace_created_successfully": "ワークスペースが正常に作成されました",
|
||||
@@ -507,7 +505,7 @@
|
||||
"forgot_password_email_change_password": "パスワードを変更",
|
||||
"forgot_password_email_did_not_request": "このリクエストに心当たりのない場合は、このメールを無視してください。",
|
||||
"forgot_password_email_heading": "パスワードを変更",
|
||||
"forgot_password_email_link_valid_for_24_hours": "このリンクは24時間有効です。",
|
||||
"forgot_password_email_link_valid_for_24_hours": "このリンクは{minutes}分間有効です。",
|
||||
"forgot_password_email_subject": "Formbricksのパスワードをリセットしてください",
|
||||
"forgot_password_email_text": "パスワード変更のリンクがリクエストされました。以下のリンクをクリックして変更できます。",
|
||||
"hidden_field": "非表示フィールド",
|
||||
@@ -1131,6 +1129,13 @@
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "Formbricksの全機能をアンロック。30日間無料。"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "データエンリッチメントと分析(AI)",
|
||||
"ai_data_analysis_enabled_description": "AIを活用してデータから最大限の価値を引き出し、ダッシュボード、チャート、レポートなどを設定できます。エクスペリエンスデータに触れます。",
|
||||
"ai_enabled": "Formbricks AI",
|
||||
"ai_enabled_description": "この組織のAI機能を管理します。",
|
||||
"ai_settings_updated_successfully": "AI設定が正常に更新されました",
|
||||
"ai_smart_tools_enabled": "スマート機能(AI)",
|
||||
"ai_smart_tools_enabled_description": "AIを活用して、より短時間でより多くのことを達成できます。Formbricksで収集されたデータには一切触れません。アンケートを他の言語に翻訳するなどの用途にのみ使用されます。",
|
||||
"bulk_invite_warning_description": "無料プランでは、すべての組織メンバーに常に「オーナー」ロールが割り当てられます。",
|
||||
"cannot_delete_only_organization": "これはあなたの唯一の組織です。削除できません。まず新しい組織を作成してください。",
|
||||
"cannot_leave_only_organization": "これはあなたの唯一の組織であるため、離れることはできません。まず新しい組織を作成してください。",
|
||||
@@ -2614,8 +2619,8 @@
|
||||
"csat_question_1_headline": "この$[projectName]を友人や同僚に勧める可能性はどのくらいありますか?",
|
||||
"csat_question_1_lower_label": "可能性が低い",
|
||||
"csat_question_1_upper_label": "可能性が非常に高い",
|
||||
"csat_question_2_choice_1": "やや満足",
|
||||
"csat_question_2_choice_2": "非常に満足",
|
||||
"csat_question_2_choice_1": "非常に満足",
|
||||
"csat_question_2_choice_2": "やや満足",
|
||||
"csat_question_2_choice_3": "満足も不満もない",
|
||||
"csat_question_2_choice_4": "やや不満",
|
||||
"csat_question_2_choice_5": "非常に不満",
|
||||
@@ -3337,18 +3342,5 @@
|
||||
"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": "ご入力いただいた内容は、より実用的なWorkflows機能の開発に役立てます。進捗は順次ご案内します。",
|
||||
"thank_you_title": "フィードバックありがとうございます!"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,7 +400,6 @@
|
||||
"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",
|
||||
@@ -471,7 +470,6 @@
|
||||
"website_survey": "Website-enquête",
|
||||
"weeks": "weken",
|
||||
"welcome_card": "Welkomstkaart",
|
||||
"workflows": "Workflows",
|
||||
"workspace": "Werkruimte",
|
||||
"workspace_configuration": "Werkruimte-configuratie",
|
||||
"workspace_created_successfully": "Project succesvol aangemaakt",
|
||||
@@ -507,7 +505,7 @@
|
||||
"forgot_password_email_change_password": "Wachtwoord wijzigen",
|
||||
"forgot_password_email_did_not_request": "Als u dit niet heeft aangevraagd, kunt u deze e-mail negeren.",
|
||||
"forgot_password_email_heading": "Wachtwoord wijzigen",
|
||||
"forgot_password_email_link_valid_for_24_hours": "De link is 24 uur geldig.",
|
||||
"forgot_password_email_link_valid_for_24_hours": "De link is {minutes} minuten geldig.",
|
||||
"forgot_password_email_subject": "Reset uw Formbricks-wachtwoord",
|
||||
"forgot_password_email_text": "U heeft een link aangevraagd om uw wachtwoord te wijzigen. Dit kunt u doen door op onderstaande link te klikken:",
|
||||
"hidden_field": "Verborgen veld",
|
||||
@@ -1131,6 +1129,13 @@
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "Ontgrendel de volledige kracht van Formbricks. 30 dagen gratis."
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Dataverrijking & analyse (AI)",
|
||||
"ai_data_analysis_enabled_description": "AI om meer uit je data te halen, dashboards op te zetten, grafieken, rapporten en meer. Raakt je ervaringsdata aan.",
|
||||
"ai_enabled": "Formbricks AI",
|
||||
"ai_enabled_description": "Beheer AI-functies voor deze organisatie.",
|
||||
"ai_settings_updated_successfully": "AI-instellingen succesvol bijgewerkt",
|
||||
"ai_smart_tools_enabled": "Slimme functionaliteit (AI)",
|
||||
"ai_smart_tools_enabled_description": "AI om je te helpen meer te bereiken in minder tijd. Raakt nooit data aan die met Formbricks is verzameld. Wordt alleen gebruikt om bijvoorbeeld enquêtes naar andere talen te vertalen.",
|
||||
"bulk_invite_warning_description": "Bij het gratis abonnement krijgen alle organisatieleden altijd de rol 'Eigenaar' toegewezen.",
|
||||
"cannot_delete_only_organization": "Dit is uw enige organisatie. Deze kan niet worden verwijderd. Maak eerst een nieuwe organisatie aan.",
|
||||
"cannot_leave_only_organization": "U kunt deze organisatie niet verlaten, aangezien dit uw enige organisatie is. Maak eerst een nieuwe organisatie aan.",
|
||||
@@ -2614,8 +2619,8 @@
|
||||
"csat_question_1_headline": "Hoe waarschijnlijk is het dat u deze $[projectName] zou aanbevelen aan een vriend of collega?",
|
||||
"csat_question_1_lower_label": "Niet waarschijnlijk",
|
||||
"csat_question_1_upper_label": "Zeer waarschijnlijk",
|
||||
"csat_question_2_choice_1": "Enigszins tevreden",
|
||||
"csat_question_2_choice_2": "Zeer tevreden",
|
||||
"csat_question_2_choice_1": "Zeer tevreden",
|
||||
"csat_question_2_choice_2": "Enigszins tevreden",
|
||||
"csat_question_2_choice_3": "Noch tevreden, noch ontevreden",
|
||||
"csat_question_2_choice_4": "Enigszins ontevreden",
|
||||
"csat_question_2_choice_5": "Zeer ontevreden",
|
||||
@@ -3337,18 +3342,5 @@
|
||||
"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 u wilt toevoegen?",
|
||||
"follow_up_placeholder": "Welke specifieke taken wil je automatiseren? Zijn er tools of integraties die je wilt meenemen?",
|
||||
"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 jij echt nodig hebt. We houden je op de hoogte van onze voortgang.",
|
||||
"thank_you_title": "Bedankt voor je feedback!"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,7 +400,6 @@
|
||||
"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",
|
||||
@@ -471,7 +470,6 @@
|
||||
"website_survey": "Pesquisa de Site",
|
||||
"weeks": "semanas",
|
||||
"welcome_card": "Cartão de boas-vindas",
|
||||
"workflows": "Fluxos de trabalho",
|
||||
"workspace": "Espaço de trabalho",
|
||||
"workspace_configuration": "Configuração do projeto",
|
||||
"workspace_created_successfully": "Projeto criado com sucesso",
|
||||
@@ -507,7 +505,7 @@
|
||||
"forgot_password_email_change_password": "Mudar senha",
|
||||
"forgot_password_email_did_not_request": "Se você não solicitou isso, por favor ignore este e-mail.",
|
||||
"forgot_password_email_heading": "Mudar senha",
|
||||
"forgot_password_email_link_valid_for_24_hours": "O link é válido por 24 horas.",
|
||||
"forgot_password_email_link_valid_for_24_hours": "O link é válido por {minutes} minutos.",
|
||||
"forgot_password_email_subject": "Redefinir sua senha Formbricks",
|
||||
"forgot_password_email_text": "Você pediu um link pra trocar sua senha. Você pode fazer isso clicando no link abaixo:",
|
||||
"hidden_field": "Campo oculto",
|
||||
@@ -1131,6 +1129,13 @@
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "Desbloqueie todo o poder do Formbricks. Grátis por 30 dias."
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Enriquecimento e análise de dados (IA)",
|
||||
"ai_data_analysis_enabled_description": "IA para extrair mais dos seus dados, configurar dashboards, gráficos, relatórios e muito mais. Acessa os dados da sua experiência.",
|
||||
"ai_enabled": "Formbricks AI",
|
||||
"ai_enabled_description": "Gerencie recursos com IA para esta organização.",
|
||||
"ai_settings_updated_successfully": "Configurações de IA atualizadas com sucesso",
|
||||
"ai_smart_tools_enabled": "Funcionalidades inteligentes (IA)",
|
||||
"ai_smart_tools_enabled_description": "IA para ajudar você a conquistar mais em menos tempo. Nunca acessa dados coletados com o Formbricks. Usado apenas para, por exemplo, traduzir pesquisas para outros idiomas.",
|
||||
"bulk_invite_warning_description": "Por favor, note que no Plano Gratuito, todos os membros da organização são automaticamente atribuídos ao papel de 'Owner', independentemente do papel especificado no arquivo CSV.",
|
||||
"cannot_delete_only_organization": "Essa é sua única organização, não pode ser deletada. Crie uma nova organização primeiro.",
|
||||
"cannot_leave_only_organization": "Você não pode sair dessa organização porque é a sua única. Crie uma nova organização primeiro.",
|
||||
@@ -2614,8 +2619,8 @@
|
||||
"csat_question_1_headline": "Qual a probabilidade de você recomendar este $[projectName] para um amigo ou colega?",
|
||||
"csat_question_1_lower_label": "Pouco provável",
|
||||
"csat_question_1_upper_label": "Muito provável",
|
||||
"csat_question_2_choice_1": "Meio satisfeito",
|
||||
"csat_question_2_choice_2": "Muito satisfeito",
|
||||
"csat_question_2_choice_1": "Muito satisfeito",
|
||||
"csat_question_2_choice_2": "Meio satisfeito",
|
||||
"csat_question_2_choice_3": "Nem satisfeito nem insatisfeito",
|
||||
"csat_question_2_choice_4": "Um pouco insatisfeito",
|
||||
"csat_question_2_choice_5": "Muito insatisfeito",
|
||||
@@ -3337,18 +3342,5 @@
|
||||
"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 acrescentar?",
|
||||
"follow_up_placeholder": "Quais tarefas específicas você gostaria de automatizar? Alguma ferramenta ou integração que 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 a funcionalidade de Fluxos de Trabalho que você realmente precisa. Manteremos você informado sobre nosso progresso.",
|
||||
"thank_you_title": "Obrigado pelo seu feedback!"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,7 +400,6 @@
|
||||
"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",
|
||||
@@ -471,7 +470,6 @@
|
||||
"website_survey": "Inquérito do Website",
|
||||
"weeks": "semanas",
|
||||
"welcome_card": "Cartão de boas-vindas",
|
||||
"workflows": "Fluxos de trabalho",
|
||||
"workspace": "Espaço de trabalho",
|
||||
"workspace_configuration": "Configuração do projeto",
|
||||
"workspace_created_successfully": "Projeto criado com sucesso",
|
||||
@@ -507,7 +505,7 @@
|
||||
"forgot_password_email_change_password": "Alterar palavra-passe",
|
||||
"forgot_password_email_did_not_request": "Se não solicitou isto, por favor ignore este email.",
|
||||
"forgot_password_email_heading": "Alterar palavra-passe",
|
||||
"forgot_password_email_link_valid_for_24_hours": "O link é válido por 24 horas.",
|
||||
"forgot_password_email_link_valid_for_24_hours": "O link é válido por {minutes} minutos.",
|
||||
"forgot_password_email_subject": "Redefina a sua palavra-passe do Formbricks",
|
||||
"forgot_password_email_text": "Solicitou um link para alterar a sua palavra-passe. Pode fazê-lo clicando no link abaixo:",
|
||||
"hidden_field": "Campo oculto",
|
||||
@@ -1131,6 +1129,13 @@
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "Desbloqueie todo o poder do Formbricks. Grátis por 30 dias."
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Enriquecimento e análise de dados (IA)",
|
||||
"ai_data_analysis_enabled_description": "IA para tirar mais partido dos teus dados, configurar dashboards, gráficos, relatórios e muito mais. Acede aos dados da tua experiência.",
|
||||
"ai_enabled": "IA da Formbricks",
|
||||
"ai_enabled_description": "Gerir funcionalidades com IA para esta organização.",
|
||||
"ai_settings_updated_successfully": "Definições de IA atualizadas com sucesso",
|
||||
"ai_smart_tools_enabled": "Funcionalidade inteligente (IA)",
|
||||
"ai_smart_tools_enabled_description": "IA para te ajudar a alcançar mais em menos tempo. Nunca acede aos dados recolhidos com o Formbricks. Apenas usado para, por exemplo, traduzir inquéritos para outros idiomas.",
|
||||
"bulk_invite_warning_description": "No plano gratuito, todos os membros da organização são sempre atribuídos ao papel de \"Proprietário\".",
|
||||
"cannot_delete_only_organization": "Esta é a sua única organização, não pode ser eliminada. Crie uma nova organização primeiro.",
|
||||
"cannot_leave_only_organization": "Não pode sair desta organização, pois é a sua única organização. Crie uma nova organização primeiro.",
|
||||
@@ -2614,8 +2619,8 @@
|
||||
"csat_question_1_headline": "Qual a probabilidade de recomendar este $[projectName] a um amigo ou colega?",
|
||||
"csat_question_1_lower_label": "Pouco provável",
|
||||
"csat_question_1_upper_label": "Muito provável",
|
||||
"csat_question_2_choice_1": "Algo satisfeito",
|
||||
"csat_question_2_choice_2": "Muito satisfeito",
|
||||
"csat_question_2_choice_1": "Muito satisfeito",
|
||||
"csat_question_2_choice_2": "Algo satisfeito",
|
||||
"csat_question_2_choice_3": "Nem satisfeito nem insatisfeito",
|
||||
"csat_question_2_choice_4": "Algo insatisfeito",
|
||||
"csat_question_2_choice_5": "Muito insatisfeito",
|
||||
@@ -3337,18 +3342,5 @@
|
||||
"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? Existe alguma ferramenta ou integração que queira incluir?",
|
||||
"generate_button": "Gerar fluxo de trabalho",
|
||||
"heading": "Que fluxo de trabalho quer criar?",
|
||||
"placeholder": "Descreva o fluxo de trabalho que pretende gerar…",
|
||||
"subheading": "Gere o seu fluxo de trabalho em segundos.",
|
||||
"submit_button": "Adicionar detalhes",
|
||||
"thank_you_description": "A sua contribuição ajuda-nos a criar a funcionalidade Workflows de que realmente precisa. Mantê-lo-emos informado sobre o nosso progresso.",
|
||||
"thank_you_title": "Obrigado pelo seu feedback!"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,7 +400,6 @@
|
||||
"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",
|
||||
@@ -471,7 +470,6 @@
|
||||
"website_survey": "Chestionar despre site",
|
||||
"weeks": "săptămâni",
|
||||
"welcome_card": "Card de bun venit",
|
||||
"workflows": "Workflows",
|
||||
"workspace": "Spațiu de lucru",
|
||||
"workspace_configuration": "Configurare workspace",
|
||||
"workspace_created_successfully": "Spațiul de lucru a fost creat cu succes",
|
||||
@@ -507,7 +505,7 @@
|
||||
"forgot_password_email_change_password": "Schimbați parola",
|
||||
"forgot_password_email_did_not_request": "Dacă nu ați solicitat acest lucru, vă rugăm să ignorați acest email.",
|
||||
"forgot_password_email_heading": "Schimbați parola",
|
||||
"forgot_password_email_link_valid_for_24_hours": "Linkul este valabil timp de 24 de ore.",
|
||||
"forgot_password_email_link_valid_for_24_hours": "Linkul este valabil timp de {minutes} minute.",
|
||||
"forgot_password_email_subject": "Resetați parola dumneavoastră Formbricks",
|
||||
"forgot_password_email_text": "Ați solicitat un link pentru a vă schimba parola. Puteți face acest lucru făcând clic pe linkul de mai jos:",
|
||||
"hidden_field": "Câmp ascuns",
|
||||
@@ -1131,6 +1129,13 @@
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "Deblocați puterea completă a Formbricks. Gratuit timp de 30 de zile."
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Îmbogățire și analiză de date (AI)",
|
||||
"ai_data_analysis_enabled_description": "AI pentru a obține mai mult din datele tale, configurare dashboard-uri, grafice, rapoarte și multe altele. Accesează datele tale de experiență.",
|
||||
"ai_enabled": "Formbricks AI",
|
||||
"ai_enabled_description": "Gestionează funcționalitățile bazate pe AI pentru această organizație.",
|
||||
"ai_settings_updated_successfully": "Setările AI au fost actualizate cu succes",
|
||||
"ai_smart_tools_enabled": "Funcționalitate inteligentă (AI)",
|
||||
"ai_smart_tools_enabled_description": "AI care te ajută să faci mai mult în mai puțin timp. Nu accesează niciodată datele colectate cu Formbricks. Folosit doar, de exemplu, pentru a traduce chestionare în alte limbi.",
|
||||
"bulk_invite_warning_description": "În planul gratuit, toți membrii organizației sunt întotdeauna alocați rolului „Proprietar”.",
|
||||
"cannot_delete_only_organization": "Aceasta este singura ta organizație, nu poate fi ștearsă. Creează mai întâi o nouă organizație.",
|
||||
"cannot_leave_only_organization": "Nu poți părăsi această organizație deoarece este singura ta organizație. Creează mai întâi o nouă organizație.",
|
||||
@@ -2614,8 +2619,8 @@
|
||||
"csat_question_1_headline": "Cât de probabil este ca să recomandați acest $[projectName] unui prieten sau coleg?",
|
||||
"csat_question_1_lower_label": "Puțin probabil",
|
||||
"csat_question_1_upper_label": "Foarte probabil",
|
||||
"csat_question_2_choice_1": "Puțin mulțumit",
|
||||
"csat_question_2_choice_2": "Foarte mulțumit",
|
||||
"csat_question_2_choice_1": "Foarte mulțumit",
|
||||
"csat_question_2_choice_2": "Puțin mulțumit",
|
||||
"csat_question_2_choice_3": "Nici mulțumit, nici nemulțumit",
|
||||
"csat_question_2_choice_4": "Ușor nemulțumit",
|
||||
"csat_question_2_choice_5": "Foarte nemulțumit",
|
||||
@@ -3337,18 +3342,5 @@
|
||||
"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 ați dori să adăugați?",
|
||||
"follow_up_placeholder": "Ce sarcini specifice ați dori să automatizați? Există instrumente sau integrări pe care ați dori să le includem?",
|
||||
"generate_button": "Generează workflow",
|
||||
"heading": "Ce workflow vrei să creezi?",
|
||||
"placeholder": "Descrieți fluxul de lucru pe care doriți să-l generați…",
|
||||
"subheading": "Generează-ți workflow-ul în câteva secunde.",
|
||||
"submit_button": "Adaugă detalii",
|
||||
"thank_you_description": "Contribuția dvs. ne ajută să dezvoltăm funcția Workflows de care chiar aveți nevoie. Vă vom ține la curent cu progresul nostru.",
|
||||
"thank_you_title": "Îți mulțumim pentru feedback!"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,7 +400,6 @@
|
||||
"show_response_count": "Показать количество ответов",
|
||||
"shown": "Показано",
|
||||
"size": "Размер",
|
||||
"skip": "Пропустить",
|
||||
"skipped": "Пропущено",
|
||||
"skips": "Пропуски",
|
||||
"some_files_failed_to_upload": "Не удалось загрузить некоторые файлы",
|
||||
@@ -471,7 +470,6 @@
|
||||
"website_survey": "Опрос сайта",
|
||||
"weeks": "недели",
|
||||
"welcome_card": "Приветственная карточка",
|
||||
"workflows": "Воркфлоу",
|
||||
"workspace": "Рабочее пространство",
|
||||
"workspace_configuration": "Настройка рабочего пространства",
|
||||
"workspace_created_successfully": "Рабочий проект успешно создан",
|
||||
@@ -507,7 +505,7 @@
|
||||
"forgot_password_email_change_password": "Сменить пароль",
|
||||
"forgot_password_email_did_not_request": "Если вы не запрашивали это, просто проигнорируйте это письмо.",
|
||||
"forgot_password_email_heading": "Сменить пароль",
|
||||
"forgot_password_email_link_valid_for_24_hours": "Ссылка действительна в течение 24 часов.",
|
||||
"forgot_password_email_link_valid_for_24_hours": "Ссылка действительна в течение {minutes} минут.",
|
||||
"forgot_password_email_subject": "Сбросьте свой пароль Formbricks",
|
||||
"forgot_password_email_text": "Вы запросили ссылку для смены пароля. Вы можете сделать это, перейдя по ссылке ниже:",
|
||||
"hidden_field": "Скрытое поле",
|
||||
@@ -1131,6 +1129,13 @@
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "Откройте все возможности Formbricks. Бесплатно на 30 дней."
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Обогащение и анализ данных (ИИ)",
|
||||
"ai_data_analysis_enabled_description": "ИИ для получения большего от твоих данных: настройка дашбордов, графиков, отчетов и не только. Работает с твоими данными об опыте.",
|
||||
"ai_enabled": "Formbricks AI",
|
||||
"ai_enabled_description": "Управляй функциями на базе ИИ для этой организации.",
|
||||
"ai_settings_updated_successfully": "Настройки AI успешно обновлены",
|
||||
"ai_smart_tools_enabled": "Умные функции (ИИ)",
|
||||
"ai_smart_tools_enabled_description": "ИИ помогает тебе делать больше за меньшее время. Никогда не использует данные, собранные с помощью Formbricks. Применяется, например, для перевода опросов на другие языки.",
|
||||
"bulk_invite_warning_description": "В бесплатном тарифе всем участникам организации всегда назначается роль \"Владелец\".",
|
||||
"cannot_delete_only_organization": "Это ваша единственная организация, её нельзя удалить. Сначала создайте новую организацию.",
|
||||
"cannot_leave_only_organization": "Вы не можете покинуть эту организацию, так как она у вас единственная. Сначала создайте новую организацию.",
|
||||
@@ -2614,8 +2619,8 @@
|
||||
"csat_question_1_headline": "Насколько вероятно, что вы порекомендуете $[projectName] другу или коллеге?",
|
||||
"csat_question_1_lower_label": "Маловероятно",
|
||||
"csat_question_1_upper_label": "Очень вероятно",
|
||||
"csat_question_2_choice_1": "В целом доволен",
|
||||
"csat_question_2_choice_2": "Очень доволен",
|
||||
"csat_question_2_choice_1": "Очень доволен",
|
||||
"csat_question_2_choice_2": "В целом доволен",
|
||||
"csat_question_2_choice_3": "Ни доволен, ни недоволен",
|
||||
"csat_question_2_choice_4": "В целом недоволен",
|
||||
"csat_question_2_choice_5": "Очень недоволен",
|
||||
@@ -3337,18 +3342,5 @@
|
||||
"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": "Опишите, какой сценарий (workflow) вы хотите создать…",
|
||||
"subheading": "Сгенерируй свой воркфлоу за секунды.",
|
||||
"submit_button": "Добавить детали",
|
||||
"thank_you_description": "Ваш вклад помогает нам создавать функцию сценариев, которая действительно вам нужна. Мы будем держать вас в курсе нашего прогресса.",
|
||||
"thank_you_title": "Спасибо за твой отзыв!"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,7 +400,6 @@
|
||||
"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",
|
||||
@@ -471,7 +470,6 @@
|
||||
"website_survey": "Webbplatsenkät",
|
||||
"weeks": "veckor",
|
||||
"welcome_card": "Välkomstkort",
|
||||
"workflows": "Arbetsflöden",
|
||||
"workspace": "Arbetsyta",
|
||||
"workspace_configuration": "Arbetsytans konfiguration",
|
||||
"workspace_created_successfully": "Arbetsytan har skapats",
|
||||
@@ -507,7 +505,7 @@
|
||||
"forgot_password_email_change_password": "Ändra lösenord",
|
||||
"forgot_password_email_did_not_request": "Om du inte begärde detta, vänligen ignorera detta e-postmeddelande.",
|
||||
"forgot_password_email_heading": "Ändra lösenord",
|
||||
"forgot_password_email_link_valid_for_24_hours": "Länken är giltig i 24 timmar.",
|
||||
"forgot_password_email_link_valid_for_24_hours": "Länken är giltig i {minutes} minuter.",
|
||||
"forgot_password_email_subject": "Återställ ditt Formbricks-lösenord",
|
||||
"forgot_password_email_text": "Du har begärt en länk för att ändra ditt lösenord. Du kan göra detta genom att klicka på länken nedan:",
|
||||
"hidden_field": "Dolt fält",
|
||||
@@ -1131,6 +1129,13 @@
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "Lås upp Formbricks fulla kraft. Gratis i 30 dagar."
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Dataförbättring & analys (AI)",
|
||||
"ai_data_analysis_enabled_description": "AI för att få ut mer av din data, skapa dashboards, diagram, rapporter och mer. Använder din upplevelsedata.",
|
||||
"ai_enabled": "Formbricks AI",
|
||||
"ai_enabled_description": "Hantera AI-drivna funktioner för den här organisationen.",
|
||||
"ai_settings_updated_successfully": "AI-inställningarna har uppdaterats",
|
||||
"ai_smart_tools_enabled": "Smarta funktioner (AI)",
|
||||
"ai_smart_tools_enabled_description": "AI som hjälper dig att göra mer på kortare tid. Rör aldrig data som samlats in med Formbricks. Används bara till t.ex. att översätta enkäter till andra språk.",
|
||||
"bulk_invite_warning_description": "På gratisplanen tilldelas alla organisationsmedlemmar alltid rollen \"Ägare\".",
|
||||
"cannot_delete_only_organization": "Detta är din enda organisation, den kan inte tas bort. Skapa en ny organisation först.",
|
||||
"cannot_leave_only_organization": "Du kan inte lämna denna organisation eftersom det är din enda organisation. Skapa en ny organisation först.",
|
||||
@@ -2614,8 +2619,8 @@
|
||||
"csat_question_1_headline": "Hur troligt är det att du skulle rekommendera $[projectName] till en vän eller kollega?",
|
||||
"csat_question_1_lower_label": "Inte troligt",
|
||||
"csat_question_1_upper_label": "Mycket troligt",
|
||||
"csat_question_2_choice_1": "Ganska nöjd",
|
||||
"csat_question_2_choice_2": "Mycket nöjd",
|
||||
"csat_question_2_choice_1": "Mycket nöjd",
|
||||
"csat_question_2_choice_2": "Ganska nöjd",
|
||||
"csat_question_2_choice_3": "Varken nöjd eller missnöjd",
|
||||
"csat_question_2_choice_4": "Ganska missnöjd",
|
||||
"csat_question_2_choice_5": "Mycket missnöjd",
|
||||
@@ -3337,18 +3342,5 @@
|
||||
"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": "Finns det något annat du vill lägga till?",
|
||||
"follow_up_placeholder": "Vilka specifika uppgifter vill du automatisera? Några verktyg eller integrationer du vill ha med?",
|
||||
"generate_button": "Skapa arbetsflöde",
|
||||
"heading": "Vilket arbetsflöde vill du skapa?",
|
||||
"placeholder": "Beskriv det arbetsflöde 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 våra framsteg.",
|
||||
"thank_you_title": "Tack för din feedback!"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,7 +400,6 @@
|
||||
"show_response_count": "显示 响应 计数",
|
||||
"shown": "显示",
|
||||
"size": "尺寸",
|
||||
"skip": "跳过",
|
||||
"skipped": "跳过",
|
||||
"skips": "跳过",
|
||||
"some_files_failed_to_upload": "某些文件上传失败",
|
||||
@@ -471,7 +470,6 @@
|
||||
"website_survey": "网站 调查",
|
||||
"weeks": "周",
|
||||
"welcome_card": "欢迎 卡片",
|
||||
"workflows": "工作流",
|
||||
"workspace": "工作区",
|
||||
"workspace_configuration": "工作区配置",
|
||||
"workspace_created_successfully": "工作区创建成功",
|
||||
@@ -507,7 +505,7 @@
|
||||
"forgot_password_email_change_password": "更改 密码",
|
||||
"forgot_password_email_did_not_request": "如果您 未 请求此 项 ,请 忽略 此邮件 。",
|
||||
"forgot_password_email_heading": "更改 密码",
|
||||
"forgot_password_email_link_valid_for_24_hours": "链接在 24 小时 内有效。",
|
||||
"forgot_password_email_link_valid_for_24_hours": "链接在{minutes}分钟内有效。",
|
||||
"forgot_password_email_subject": "重置您的 Formbricks 密码",
|
||||
"forgot_password_email_text": "您 已 请求 一个 链接 来 更改 您的 密码。 您 可以 点击 下方 链接 完成 这个 操作:",
|
||||
"hidden_field": "隐藏字段",
|
||||
@@ -1131,6 +1129,13 @@
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "解锁 Formbricks 的全部功能。免费使用 30 天。"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "数据增强与分析(AI)",
|
||||
"ai_data_analysis_enabled_description": "使用 AI 深度挖掘你的数据,设置仪表盘、图表、报告等。会处理你的体验数据。",
|
||||
"ai_enabled": "Formbricks AI",
|
||||
"ai_enabled_description": "管理该组织的 AI 驱动功能。",
|
||||
"ai_settings_updated_successfully": "AI 设置已成功更新",
|
||||
"ai_smart_tools_enabled": "智能功能(AI)",
|
||||
"ai_smart_tools_enabled_description": "AI 帮你更高效地完成更多任务。绝不会接触 Formbricks 收集的数据,仅用于如问卷翻译等功能。",
|
||||
"bulk_invite_warning_description": "在免费计划中,所有组织成员都会被分配为 \"Owner \"角色。",
|
||||
"cannot_delete_only_organization": "这是 您 唯一的 组织,不可 删除。请 先 创建一个新的 组织。",
|
||||
"cannot_leave_only_organization": "您 不能 离开 此 组织,因为 这是 您 唯一的 组织。请 先 创建一个新的 组织。",
|
||||
@@ -2614,8 +2619,8 @@
|
||||
"csat_question_1_headline": "您有多大可能向朋友或同事推荐这款 $[projectName] ?",
|
||||
"csat_question_1_lower_label": "不可能",
|
||||
"csat_question_1_upper_label": "非常 可能",
|
||||
"csat_question_2_choice_1": "有点 满意",
|
||||
"csat_question_2_choice_2": "非常 满意",
|
||||
"csat_question_2_choice_1": "非常 满意",
|
||||
"csat_question_2_choice_2": "有点 满意",
|
||||
"csat_question_2_choice_3": "既不 满意 也 不 不满意",
|
||||
"csat_question_2_choice_4": "有点 不满意",
|
||||
"csat_question_2_choice_5": "非常 不 满意",
|
||||
@@ -3337,18 +3342,5 @@
|
||||
"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": "感谢你的反馈!"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,7 +400,6 @@
|
||||
"show_response_count": "顯示回應數",
|
||||
"shown": "已顯示",
|
||||
"size": "大小",
|
||||
"skip": "略過",
|
||||
"skipped": "已跳過",
|
||||
"skips": "跳過次數",
|
||||
"some_files_failed_to_upload": "部分檔案上傳失敗",
|
||||
@@ -471,7 +470,6 @@
|
||||
"website_survey": "網站問卷",
|
||||
"weeks": "週",
|
||||
"welcome_card": "歡迎卡片",
|
||||
"workflows": "工作流程",
|
||||
"workspace": "工作區",
|
||||
"workspace_configuration": "工作區設定",
|
||||
"workspace_created_successfully": "工作區已成功建立",
|
||||
@@ -507,7 +505,7 @@
|
||||
"forgot_password_email_change_password": "變更密碼",
|
||||
"forgot_password_email_did_not_request": "如果您沒有要求此操作,請忽略此電子郵件。",
|
||||
"forgot_password_email_heading": "變更密碼",
|
||||
"forgot_password_email_link_valid_for_24_hours": "此連結有效期為 24 小時。",
|
||||
"forgot_password_email_link_valid_for_24_hours": "此連結有效期為 {minutes} 分鐘。",
|
||||
"forgot_password_email_subject": "重設您的 Formbricks 密碼",
|
||||
"forgot_password_email_text": "您已請求變更密碼的連結。您可以點擊以下連結來執行此操作:",
|
||||
"hidden_field": "隱藏欄位",
|
||||
@@ -1131,6 +1129,13 @@
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "免費解鎖 Formbricks 的全部功能,為期 30 天。"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "資料增強與分析(AI)",
|
||||
"ai_data_analysis_enabled_description": "利用 AI 深入分析你的資料,建立儀表板、圖表、報告等。會處理你的體驗資料。",
|
||||
"ai_enabled": "Formbricks AI",
|
||||
"ai_enabled_description": "管理此組織的 AI 功能。",
|
||||
"ai_settings_updated_successfully": "AI 設定已成功更新",
|
||||
"ai_smart_tools_enabled": "智慧功能(AI)",
|
||||
"ai_smart_tools_enabled_description": "AI 幫你更快完成更多事。絕不會接觸 Formbricks 收集的資料,只用於像是將問卷翻譯成其他語言等用途。",
|
||||
"bulk_invite_warning_description": "在免費方案中,所有組織成員始終會被指派「擁有者」角色。",
|
||||
"cannot_delete_only_organization": "這是您唯一的組織,無法刪除。請先建立新組織。",
|
||||
"cannot_leave_only_organization": "您無法離開此組織,因為它是您唯一的組織。請先建立新組織。",
|
||||
@@ -2614,8 +2619,8 @@
|
||||
"csat_question_1_headline": "您向朋友或同事推薦此 {projectName} 的可能性有多高?",
|
||||
"csat_question_1_lower_label": "不太可能",
|
||||
"csat_question_1_upper_label": "非常可能",
|
||||
"csat_question_2_choice_1": "有點滿意",
|
||||
"csat_question_2_choice_2": "非常滿意",
|
||||
"csat_question_2_choice_1": "非常滿意",
|
||||
"csat_question_2_choice_2": "有點滿意",
|
||||
"csat_question_2_choice_3": "既不滿意也不不滿意",
|
||||
"csat_question_2_choice_4": "有點不滿意",
|
||||
"csat_question_2_choice_5": "非常不滿意",
|
||||
@@ -3337,18 +3342,5 @@
|
||||
"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": "感謝你的回饋!"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { requestPasswordReset } from "@/modules/auth/forgot-password/lib/password-reset-service";
|
||||
import { getUserByEmail } from "@/modules/auth/lib/user";
|
||||
// Import mocked functions
|
||||
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { sendForgotPasswordEmail } from "@/modules/email";
|
||||
import { forgotPasswordAction } from "./actions";
|
||||
|
||||
// Mock dependencies
|
||||
@@ -27,8 +28,14 @@ vi.mock("@/modules/auth/lib/user", () => ({
|
||||
getUserByEmail: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/email", () => ({
|
||||
sendForgotPasswordEmail: vi.fn(),
|
||||
vi.mock("@/modules/auth/forgot-password/lib/password-reset-service", () => ({
|
||||
requestPasswordReset: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/action-client", () => ({
|
||||
@@ -77,7 +84,7 @@ describe("forgotPasswordAction", () => {
|
||||
);
|
||||
|
||||
expect(getUserByEmail).not.toHaveBeenCalled();
|
||||
expect(sendForgotPasswordEmail).not.toHaveBeenCalled();
|
||||
expect(requestPasswordReset).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should use correct rate limit configuration", async () => {
|
||||
@@ -104,39 +111,39 @@ describe("forgotPasswordAction", () => {
|
||||
|
||||
describe("Password Reset Flow", () => {
|
||||
test("should send password reset email when user exists with email identity provider", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
|
||||
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
|
||||
|
||||
const result = await forgotPasswordAction({ parsedInput: validInput } as any);
|
||||
|
||||
expect(applyIPRateLimit).toHaveBeenCalled();
|
||||
expect(getUserByEmail).toHaveBeenCalledWith(validInput.email);
|
||||
expect(sendForgotPasswordEmail).toHaveBeenCalledWith(mockUser);
|
||||
expect(requestPasswordReset).toHaveBeenCalledWith(mockUser, "public");
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
test("should not send email when user doesn't exist", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
|
||||
vi.mocked(getUserByEmail).mockResolvedValue(null);
|
||||
|
||||
const result = await forgotPasswordAction({ parsedInput: validInput } as any);
|
||||
|
||||
expect(applyIPRateLimit).toHaveBeenCalled();
|
||||
expect(getUserByEmail).toHaveBeenCalledWith(validInput.email);
|
||||
expect(sendForgotPasswordEmail).not.toHaveBeenCalled();
|
||||
expect(requestPasswordReset).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
test("should not send email when user has non-email identity provider", async () => {
|
||||
const ssoUser = { ...mockUser, identityProvider: "google" };
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
|
||||
vi.mocked(getUserByEmail).mockResolvedValue(ssoUser as any);
|
||||
|
||||
const result = await forgotPasswordAction({ parsedInput: validInput } as any);
|
||||
|
||||
expect(applyIPRateLimit).toHaveBeenCalled();
|
||||
expect(getUserByEmail).toHaveBeenCalledWith(validInput.email);
|
||||
expect(sendForgotPasswordEmail).not.toHaveBeenCalled();
|
||||
expect(requestPasswordReset).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
@@ -146,7 +153,7 @@ describe("forgotPasswordAction", () => {
|
||||
// This test verifies that password reset is enabled by default
|
||||
// The actual PASSWORD_RESET_DISABLED check is part of the implementation
|
||||
// and we've mocked it as false, so rate limiting should work normally
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
|
||||
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
|
||||
|
||||
const result = await forgotPasswordAction({ parsedInput: validInput } as any);
|
||||
@@ -168,7 +175,7 @@ describe("forgotPasswordAction", () => {
|
||||
});
|
||||
|
||||
test("should handle user lookup errors after rate limiting", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
|
||||
vi.mocked(getUserByEmail).mockRejectedValue(new Error("Database error"));
|
||||
|
||||
await expect(forgotPasswordAction({ parsedInput: validInput } as any)).rejects.toThrow(
|
||||
@@ -178,23 +185,30 @@ describe("forgotPasswordAction", () => {
|
||||
expect(applyIPRateLimit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle email sending errors after rate limiting", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
test("should propagate unexpected password reset request errors after rate limiting", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
|
||||
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
|
||||
vi.mocked(sendForgotPasswordEmail).mockRejectedValue(new Error("Email service error"));
|
||||
vi.mocked(requestPasswordReset).mockRejectedValue(new Error("Password reset request failed"));
|
||||
|
||||
await expect(forgotPasswordAction({ parsedInput: validInput } as any)).rejects.toThrow(
|
||||
"Email service error"
|
||||
);
|
||||
await expect(forgotPasswordAction({ parsedInput: validInput } as any)).resolves.toEqual({
|
||||
success: true,
|
||||
});
|
||||
|
||||
expect(applyIPRateLimit).toHaveBeenCalled();
|
||||
expect(getUserByEmail).toHaveBeenCalled();
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
stage: "dispatch",
|
||||
userId: mockUser.id,
|
||||
}),
|
||||
"Password reset request failed"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Security Considerations", () => {
|
||||
test("should always return success even for non-existent users to prevent email enumeration", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
|
||||
vi.mocked(getUserByEmail).mockResolvedValue(null);
|
||||
|
||||
const result = await forgotPasswordAction({ parsedInput: validInput } as any);
|
||||
@@ -204,17 +218,17 @@ describe("forgotPasswordAction", () => {
|
||||
|
||||
test("should always return success even for SSO users to prevent identity provider enumeration", async () => {
|
||||
const ssoUser = { ...mockUser, identityProvider: "github" };
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
|
||||
vi.mocked(getUserByEmail).mockResolvedValue(ssoUser as any);
|
||||
|
||||
const result = await forgotPasswordAction({ parsedInput: validInput } as any);
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(sendForgotPasswordEmail).not.toHaveBeenCalled();
|
||||
expect(requestPasswordReset).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should rate limit all requests regardless of user existence", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
|
||||
|
||||
// Test with existing user
|
||||
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import { ZUserEmail } from "@formbricks/types/user";
|
||||
import { PASSWORD_RESET_DISABLED } from "@/lib/constants";
|
||||
import { actionClient } from "@/lib/utils/action-client";
|
||||
import { requestPasswordReset } from "@/modules/auth/forgot-password/lib/password-reset-service";
|
||||
import { getUserByEmail } from "@/modules/auth/lib/user";
|
||||
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { sendForgotPasswordEmail } from "@/modules/email";
|
||||
|
||||
const ZForgotPasswordAction = z.object({
|
||||
email: ZUserEmail,
|
||||
@@ -26,7 +27,11 @@ export const forgotPasswordAction = actionClient
|
||||
const user = await getUserByEmail(parsedInput.email);
|
||||
|
||||
if (user && user.identityProvider === "email") {
|
||||
await sendForgotPasswordEmail(user);
|
||||
try {
|
||||
await requestPasswordReset(user, "public");
|
||||
} catch (error) {
|
||||
logger.error({ error, stage: "dispatch", userId: user.id }, "Password reset request failed");
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import {
|
||||
INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE,
|
||||
InvalidPasswordResetTokenError,
|
||||
} from "@formbricks/types/errors";
|
||||
import type { TUser } from "@formbricks/types/user";
|
||||
import { hashPassword } from "@/lib/auth";
|
||||
import { hashString } from "@/lib/hash-string";
|
||||
import { sendPasswordResetLinkEmail, sendPasswordResetNotifyEmail } from "@/modules/email";
|
||||
import {
|
||||
ACCOUNT_RECOVERY_LINK_EMAIL_ERROR_CODE,
|
||||
completePasswordReset,
|
||||
getPasswordResetTokenLifetimeInMinutes,
|
||||
requestPasswordReset,
|
||||
} from "./password-reset-service";
|
||||
import type { TPasswordResetTokenRecord } from "./password-reset-token-repository";
|
||||
|
||||
type TPasswordResetTestUser = Pick<TUser, "id" | "email" | "locale" | "emailVerified"> & {
|
||||
password: string;
|
||||
};
|
||||
|
||||
type TPasswordResetAuditUserFixture = Pick<
|
||||
TPasswordResetTestUser,
|
||||
"id" | "email" | "locale" | "emailVerified"
|
||||
>;
|
||||
|
||||
type TPasswordResetTransactionStub = {
|
||||
user: {
|
||||
findUnique: (args: { where: { id: string } }) => Promise<TPasswordResetAuditUserFixture | null>;
|
||||
update: (args: {
|
||||
where: { id: string };
|
||||
data: { password: string };
|
||||
}) => Promise<TPasswordResetAuditUserFixture>;
|
||||
};
|
||||
};
|
||||
|
||||
const testState = vi.hoisted(() => {
|
||||
const tokenStore = new Map<string, TPasswordResetTokenRecord>();
|
||||
const users = new Map<string, TPasswordResetTestUser>();
|
||||
|
||||
const selectAuditUser = (user: TPasswordResetTestUser): TPasswordResetAuditUserFixture => ({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
locale: user.locale,
|
||||
emailVerified: user.emailVerified,
|
||||
});
|
||||
|
||||
const mockUpsertActiveToken = vi.fn(async (userId: string, tokenHash: string, expiresAt: Date) => {
|
||||
const existingRecord = tokenStore.get(userId);
|
||||
const now = new Date();
|
||||
const record = {
|
||||
id: existingRecord?.id ?? `prt_${userId}`,
|
||||
userId,
|
||||
tokenHash,
|
||||
expiresAt,
|
||||
createdAt: existingRecord?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
tokenStore.set(userId, record);
|
||||
return record;
|
||||
});
|
||||
|
||||
const mockFindByTokenHash = vi.fn(async (tokenHash: string) => {
|
||||
return [...tokenStore.values()].find((record) => record.tokenHash === tokenHash) ?? null;
|
||||
});
|
||||
|
||||
const mockDeleteByTokenHash = vi.fn(async (tokenHash: string) => {
|
||||
const existingRecord = [...tokenStore.values()].find((record) => record.tokenHash === tokenHash);
|
||||
|
||||
if (!existingRecord) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
tokenStore.delete(existingRecord.userId);
|
||||
return 1;
|
||||
});
|
||||
|
||||
const mockConsumeActiveToken = vi.fn(async (tokenHash: string, now: Date) => {
|
||||
const record = [...tokenStore.values()].find(
|
||||
(storedRecord) => storedRecord.tokenHash === tokenHash && storedRecord.expiresAt > now
|
||||
);
|
||||
|
||||
if (!record) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
tokenStore.delete(record.userId);
|
||||
return 1;
|
||||
});
|
||||
|
||||
const mockTransaction = vi.fn(async <T>(callback: (tx: TPasswordResetTransactionStub) => Promise<T>) => {
|
||||
const tx: TPasswordResetTransactionStub = {
|
||||
user: {
|
||||
findUnique: vi.fn(async ({ where }) => {
|
||||
const user = users.get(where.id);
|
||||
return user ? selectAuditUser(user) : null;
|
||||
}),
|
||||
update: vi.fn(async ({ where, data }) => {
|
||||
const user = users.get(where.id);
|
||||
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
const updatedUser = {
|
||||
...user,
|
||||
password: data.password,
|
||||
};
|
||||
|
||||
users.set(where.id, updatedUser);
|
||||
return selectAuditUser(updatedUser);
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
return await callback(tx);
|
||||
});
|
||||
|
||||
return {
|
||||
tokenStore,
|
||||
users,
|
||||
mockUpsertActiveToken,
|
||||
mockFindByTokenHash,
|
||||
mockDeleteByTokenHash,
|
||||
mockConsumeActiveToken,
|
||||
mockTransaction,
|
||||
};
|
||||
});
|
||||
|
||||
const constantsState = vi.hoisted(() => ({
|
||||
debugShowResetLink: false,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/hash-string", () => ({
|
||||
hashString: vi.fn((value: string) => `hash:${value}`),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: 30,
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
get DEBUG_SHOW_RESET_LINK() {
|
||||
return constantsState.debugShowResetLink;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth", () => ({
|
||||
hashPassword: vi.fn(async (password: string) => `hashed:${password}`),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/email", () => ({
|
||||
sendPasswordResetLinkEmail: vi.fn(async () => true),
|
||||
sendPasswordResetNotifyEmail: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
$transaction: testState.mockTransaction,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./password-reset-token-repository", () => ({
|
||||
upsertActiveToken: testState.mockUpsertActiveToken,
|
||||
findByTokenHash: testState.mockFindByTokenHash,
|
||||
deleteByTokenHash: testState.mockDeleteByTokenHash,
|
||||
consumeActiveToken: testState.mockConsumeActiveToken,
|
||||
}));
|
||||
|
||||
describe("password-reset-service", () => {
|
||||
const user = {
|
||||
id: "cm8z6bn2q000008l34h8g7k9m",
|
||||
email: "user@example.com",
|
||||
locale: "en-US" as const,
|
||||
};
|
||||
|
||||
const parseTokenFromResetLink = (): string => {
|
||||
const lastCall = vi.mocked(sendPasswordResetLinkEmail).mock.calls.at(-1);
|
||||
const verifyLink = lastCall?.[0]?.verifyLink;
|
||||
|
||||
if (!verifyLink) {
|
||||
throw new Error("No verify link found");
|
||||
}
|
||||
|
||||
const url = new URL(verifyLink);
|
||||
const token = url.searchParams.get("token");
|
||||
|
||||
if (!token) {
|
||||
throw new Error("No token found in verify link");
|
||||
}
|
||||
|
||||
return token;
|
||||
};
|
||||
|
||||
const parseTokenFromDebugLog = (): string => {
|
||||
const verifyLink = vi
|
||||
.mocked(logger.info)
|
||||
.mock.calls.map(([payload]) => payload?.verifyLink)
|
||||
.find((loggedVerifyLink): loggedVerifyLink is string => typeof loggedVerifyLink === "string");
|
||||
|
||||
if (!verifyLink) {
|
||||
throw new Error("No debug verify link found");
|
||||
}
|
||||
|
||||
const url = new URL(verifyLink);
|
||||
const token = url.searchParams.get("token");
|
||||
|
||||
if (!token) {
|
||||
throw new Error("No token found in debug verify link");
|
||||
}
|
||||
|
||||
return token;
|
||||
};
|
||||
|
||||
const getStoredToken = (userId: string): TPasswordResetTokenRecord => {
|
||||
const storedToken = testState.tokenStore.get(userId);
|
||||
|
||||
if (!storedToken) {
|
||||
throw new Error("No stored token found");
|
||||
}
|
||||
|
||||
return storedToken;
|
||||
};
|
||||
|
||||
const getStoredUser = (userId: string): TPasswordResetTestUser => {
|
||||
const storedUser = testState.users.get(userId);
|
||||
|
||||
if (!storedUser) {
|
||||
throw new Error("No stored user found");
|
||||
}
|
||||
|
||||
return storedUser;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
constantsState.debugShowResetLink = false;
|
||||
testState.tokenStore.clear();
|
||||
testState.users.clear();
|
||||
testState.users.set(user.id, {
|
||||
...user,
|
||||
emailVerified: null,
|
||||
password: "old-password-hash",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("issues a hashed token with the configured default lifetime", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-30T12:00:00.000Z"));
|
||||
|
||||
await requestPasswordReset(user, "public");
|
||||
|
||||
const rawToken = parseTokenFromResetLink();
|
||||
const storedToken = getStoredToken(user.id);
|
||||
|
||||
expect(getPasswordResetTokenLifetimeInMinutes()).toBe(30);
|
||||
expect(storedToken.tokenHash).toBe(`hash:${rawToken}`);
|
||||
expect(storedToken.tokenHash).not.toBe(rawToken);
|
||||
expect(storedToken.expiresAt).toEqual(new Date("2026-03-30T12:30:00.000Z"));
|
||||
expect(sendPasswordResetLinkEmail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
email: user.email,
|
||||
locale: user.locale,
|
||||
linkValidityInMinutes: 30,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("invalidates the previous token when a new reset request is issued", async () => {
|
||||
await requestPasswordReset(user, "public");
|
||||
const firstToken = parseTokenFromResetLink();
|
||||
|
||||
await requestPasswordReset(user, "public");
|
||||
const secondToken = parseTokenFromResetLink();
|
||||
|
||||
await expect(completePasswordReset(firstToken, "Password123")).rejects.toMatchObject({
|
||||
name: "InvalidPasswordResetTokenError",
|
||||
message: INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE,
|
||||
});
|
||||
|
||||
const result = await completePasswordReset(secondToken, "Password123");
|
||||
|
||||
expect(result.userId).toBe(user.id);
|
||||
expect(getStoredUser(user.id).password).toBe("hashed:Password123");
|
||||
});
|
||||
|
||||
test("rejects expired reset tokens", async () => {
|
||||
await requestPasswordReset(user, "public");
|
||||
const token = parseTokenFromResetLink();
|
||||
|
||||
testState.tokenStore.set(user.id, {
|
||||
...getStoredToken(user.id),
|
||||
expiresAt: new Date(Date.now() - 60 * 1000),
|
||||
});
|
||||
|
||||
await expect(completePasswordReset(token, "Password123")).rejects.toMatchObject({
|
||||
name: "InvalidPasswordResetTokenError",
|
||||
reason: "expired",
|
||||
message: INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE,
|
||||
});
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
stage: "consume",
|
||||
reason: "expired",
|
||||
userId: user.id,
|
||||
}),
|
||||
"Rejected password reset token"
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects unknown and legacy jwt reset tokens", async () => {
|
||||
await expect(completePasswordReset("unknown-token", "Password123")).rejects.toBeInstanceOf(
|
||||
InvalidPasswordResetTokenError
|
||||
);
|
||||
await expect(completePasswordReset("legacy.jwt.token", "Password123")).rejects.toMatchObject({
|
||||
reason: "legacy_jwt",
|
||||
message: INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE,
|
||||
});
|
||||
});
|
||||
|
||||
test("consumes a token only once", async () => {
|
||||
await requestPasswordReset(user, "public");
|
||||
const token = parseTokenFromResetLink();
|
||||
|
||||
await expect(completePasswordReset(token, "Password123")).resolves.toMatchObject({
|
||||
userId: user.id,
|
||||
});
|
||||
await expect(completePasswordReset(token, "Password123")).rejects.toBeInstanceOf(
|
||||
InvalidPasswordResetTokenError
|
||||
);
|
||||
});
|
||||
|
||||
test("allows only one successful result for concurrent token submissions", async () => {
|
||||
await requestPasswordReset(user, "public");
|
||||
const token = parseTokenFromResetLink();
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
completePasswordReset(token, "Password123"),
|
||||
completePasswordReset(token, "Password123"),
|
||||
]);
|
||||
|
||||
const fulfilledResults = results.filter((result) => result.status === "fulfilled");
|
||||
const rejectedResults = results.filter((result) => result.status === "rejected");
|
||||
|
||||
expect(fulfilledResults).toHaveLength(1);
|
||||
expect(rejectedResults).toHaveLength(1);
|
||||
expect((rejectedResults[0] as PromiseRejectedResult).reason).toBeInstanceOf(
|
||||
InvalidPasswordResetTokenError
|
||||
);
|
||||
});
|
||||
|
||||
test("revokes the issued token when email delivery fails for a public request", async () => {
|
||||
vi.mocked(sendPasswordResetLinkEmail).mockResolvedValueOnce(false);
|
||||
|
||||
await expect(requestPasswordReset(user, "public")).resolves.toBeUndefined();
|
||||
|
||||
const revokedToken = parseTokenFromResetLink();
|
||||
|
||||
expect(testState.tokenStore.size).toBe(0);
|
||||
expect(testState.mockDeleteByTokenHash).toHaveBeenCalledWith(`hash:${revokedToken}`);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: "public",
|
||||
stage: "send",
|
||||
userId: user.id,
|
||||
}),
|
||||
"Password reset request failed"
|
||||
);
|
||||
});
|
||||
|
||||
test("logs the reset link instead of sending an email when DEBUG_SHOW_RESET_LINK is enabled", async () => {
|
||||
constantsState.debugShowResetLink = true;
|
||||
|
||||
await requestPasswordReset(user, "public");
|
||||
|
||||
expect(sendPasswordResetLinkEmail).not.toHaveBeenCalled();
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
verifyLink: expect.stringMatching(/^http:\/\/localhost:3000\/auth\/forgot-password\/reset\?token=/),
|
||||
}),
|
||||
"DEBUG_SHOW_RESET_LINK is enabled; password reset email delivery skipped"
|
||||
);
|
||||
});
|
||||
|
||||
test("logs and suppresses token issuance failures for public requests", async () => {
|
||||
testState.mockUpsertActiveToken.mockRejectedValueOnce(new Error("Database unavailable"));
|
||||
|
||||
await expect(requestPasswordReset(user, "public")).resolves.toBeUndefined();
|
||||
|
||||
expect(sendPasswordResetLinkEmail).not.toHaveBeenCalled();
|
||||
expect(testState.mockDeleteByTokenHash).not.toHaveBeenCalled();
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: "public",
|
||||
stage: "issue",
|
||||
userId: user.id,
|
||||
}),
|
||||
"Password reset request failed"
|
||||
);
|
||||
});
|
||||
|
||||
test("surfaces profile reset request failures after revoking the token", async () => {
|
||||
vi.mocked(sendPasswordResetLinkEmail).mockResolvedValueOnce(false);
|
||||
|
||||
await expect(requestPasswordReset(user, "profile")).rejects.toThrow(
|
||||
ACCOUNT_RECOVERY_LINK_EMAIL_ERROR_CODE
|
||||
);
|
||||
expect(testState.tokenStore.size).toBe(0);
|
||||
});
|
||||
|
||||
test("does not roll back a successful password reset when the notification email fails", async () => {
|
||||
await requestPasswordReset(user, "public");
|
||||
const token = parseTokenFromResetLink();
|
||||
vi.mocked(sendPasswordResetNotifyEmail).mockResolvedValueOnce(false);
|
||||
|
||||
const result = await completePasswordReset(token, "Password123");
|
||||
|
||||
expect(result.userId).toBe(user.id);
|
||||
expect(getStoredUser(user.id).password).toBe("hashed:Password123");
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
stage: "notify_email",
|
||||
userId: user.id,
|
||||
}),
|
||||
"Failed to send password reset notification email"
|
||||
);
|
||||
});
|
||||
|
||||
test("skips notification email delivery when DEBUG_SHOW_RESET_LINK is enabled", async () => {
|
||||
constantsState.debugShowResetLink = true;
|
||||
|
||||
await requestPasswordReset(user, "public");
|
||||
const token = parseTokenFromDebugLog();
|
||||
|
||||
await completePasswordReset(token, "Password123");
|
||||
|
||||
expect(sendPasswordResetNotifyEmail).not.toHaveBeenCalled();
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: user.id,
|
||||
}),
|
||||
"DEBUG_SHOW_RESET_LINK is enabled; password reset notification delivery skipped"
|
||||
);
|
||||
});
|
||||
|
||||
test("validates the reset token before hashing the new password", async () => {
|
||||
await requestPasswordReset(user, "public");
|
||||
const token = parseTokenFromResetLink();
|
||||
|
||||
await completePasswordReset(token, "Password123");
|
||||
|
||||
expect(testState.mockFindByTokenHash).toHaveBeenCalledBefore(vi.mocked(hashPassword));
|
||||
expect(vi.mocked(hashPassword)).toHaveBeenCalledBefore(vi.mocked(prisma.$transaction));
|
||||
expect(hashString).toHaveBeenCalledWith(token);
|
||||
});
|
||||
|
||||
test("rejects invalid reset tokens before hashing the new password", async () => {
|
||||
await expect(completePasswordReset("unknown-token", "Password123")).rejects.toBeInstanceOf(
|
||||
InvalidPasswordResetTokenError
|
||||
);
|
||||
|
||||
expect(hashPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,325 @@
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import crypto from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import {
|
||||
INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE,
|
||||
InvalidPasswordResetTokenError,
|
||||
} from "@formbricks/types/errors";
|
||||
import type { TUserEmail, TUserLocale } from "@formbricks/types/user";
|
||||
import { ZUserEmail, ZUserLocale, ZUserPassword } from "@formbricks/types/user";
|
||||
import { hashPassword } from "@/lib/auth";
|
||||
import { DEBUG_SHOW_RESET_LINK, PASSWORD_RESET_TOKEN_LIFETIME_MINUTES, WEBAPP_URL } from "@/lib/constants";
|
||||
import { hashString } from "@/lib/hash-string";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { sendPasswordResetLinkEmail, sendPasswordResetNotifyEmail } from "@/modules/email";
|
||||
import {
|
||||
consumeActiveToken,
|
||||
deleteByTokenHash,
|
||||
findByTokenHash,
|
||||
upsertActiveToken,
|
||||
} from "./password-reset-token-repository";
|
||||
|
||||
export const ACCOUNT_RECOVERY_LINK_EMAIL_ERROR_CODE = "ERR_RECOVERY_RESET_LINK_EMAIL_FAILED";
|
||||
export const ACCOUNT_RECOVERY_NOTIFICATION_EMAIL_ERROR_CODE = "ERR_RECOVERY_RESET_NOTIFICATION_EMAIL_FAILED";
|
||||
|
||||
const ZPasswordResetSource = z.enum(["public", "profile"]);
|
||||
|
||||
const passwordResetAuditSelection = {
|
||||
id: true,
|
||||
email: true,
|
||||
locale: true,
|
||||
emailVerified: true,
|
||||
} satisfies Prisma.UserSelect;
|
||||
|
||||
type TPasswordResetRequestSource = z.infer<typeof ZPasswordResetSource>;
|
||||
|
||||
type TPasswordResetRecipient = {
|
||||
id: string;
|
||||
email: TUserEmail;
|
||||
locale: TUserLocale;
|
||||
};
|
||||
|
||||
type TPasswordResetAuditUser = Prisma.UserGetPayload<{
|
||||
select: typeof passwordResetAuditSelection;
|
||||
}>;
|
||||
|
||||
class PasswordResetLinkEmailError extends Error {
|
||||
code = ACCOUNT_RECOVERY_LINK_EMAIL_ERROR_CODE;
|
||||
|
||||
constructor() {
|
||||
super(ACCOUNT_RECOVERY_LINK_EMAIL_ERROR_CODE);
|
||||
this.name = "PasswordResetLinkEmailError";
|
||||
}
|
||||
}
|
||||
|
||||
class PasswordResetNotificationEmailError extends Error {
|
||||
code = ACCOUNT_RECOVERY_NOTIFICATION_EMAIL_ERROR_CODE;
|
||||
|
||||
constructor() {
|
||||
super(ACCOUNT_RECOVERY_NOTIFICATION_EMAIL_ERROR_CODE);
|
||||
this.name = "PasswordResetNotificationEmailError";
|
||||
}
|
||||
}
|
||||
|
||||
export const getPasswordResetTokenLifetimeInMinutes = (): number => PASSWORD_RESET_TOKEN_LIFETIME_MINUTES;
|
||||
|
||||
const buildPasswordResetLink = (token: string): string =>
|
||||
`${WEBAPP_URL}/auth/forgot-password/reset?token=${encodeURIComponent(token)}`;
|
||||
|
||||
const isLegacyPasswordResetToken = (token: string): boolean => token.split(".").length === 3;
|
||||
|
||||
const logPasswordResetRequestFailure = ({
|
||||
error,
|
||||
source,
|
||||
stage,
|
||||
userId,
|
||||
}: {
|
||||
error: unknown;
|
||||
source: TPasswordResetRequestSource;
|
||||
stage: "issue" | "send" | "revoke";
|
||||
userId: string;
|
||||
}) => {
|
||||
logger.error({ error, source, stage, userId }, "Password reset request failed");
|
||||
};
|
||||
|
||||
const logPasswordResetTokenRejection = (error: InvalidPasswordResetTokenError) => {
|
||||
logger.warn(
|
||||
{
|
||||
stage: "consume",
|
||||
reason: error.reason ?? "invalid_or_superseded",
|
||||
userId: error.userId,
|
||||
},
|
||||
"Rejected password reset token"
|
||||
);
|
||||
};
|
||||
|
||||
const createInvalidPasswordResetTokenError = (
|
||||
reason: string,
|
||||
userId?: string
|
||||
): InvalidPasswordResetTokenError =>
|
||||
new InvalidPasswordResetTokenError(INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE, reason, userId);
|
||||
|
||||
const getPasswordResetExpiry = (): Date =>
|
||||
new Date(Date.now() + getPasswordResetTokenLifetimeInMinutes() * 60 * 1000);
|
||||
|
||||
const assertEmailWasSent = (didSendEmail: boolean, error: Error): void => {
|
||||
if (!didSendEmail) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const revokeIssuedPasswordResetToken = async (
|
||||
userId: string,
|
||||
tokenHash: string,
|
||||
source: TPasswordResetRequestSource
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await deleteByTokenHash(tokenHash);
|
||||
} catch (error) {
|
||||
logPasswordResetRequestFailure({
|
||||
error,
|
||||
source,
|
||||
stage: "revoke",
|
||||
userId,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const sendPasswordResetLink = async (user: TPasswordResetRecipient, verifyLink: string): Promise<void> => {
|
||||
if (DEBUG_SHOW_RESET_LINK) {
|
||||
logger.info({ verifyLink }, "DEBUG_SHOW_RESET_LINK is enabled; password reset email delivery skipped");
|
||||
return;
|
||||
}
|
||||
|
||||
const didSendEmail = await sendPasswordResetLinkEmail({
|
||||
email: user.email,
|
||||
locale: user.locale,
|
||||
verifyLink,
|
||||
linkValidityInMinutes: getPasswordResetTokenLifetimeInMinutes(),
|
||||
});
|
||||
|
||||
assertEmailWasSent(didSendEmail, new PasswordResetLinkEmailError());
|
||||
};
|
||||
|
||||
const updatePasswordWithActiveResetToken = async (
|
||||
tokenHash: string,
|
||||
hashedPassword: string,
|
||||
now: Date
|
||||
): Promise<{
|
||||
userId: string;
|
||||
oldUser: TPasswordResetAuditUser;
|
||||
updatedUser: TPasswordResetAuditUser;
|
||||
}> =>
|
||||
prisma.$transaction(async (tx) => {
|
||||
const tokenRecord = await findByTokenHash(tokenHash, tx);
|
||||
|
||||
if (!tokenRecord) {
|
||||
throw createInvalidPasswordResetTokenError("invalid_or_superseded");
|
||||
}
|
||||
|
||||
if (tokenRecord.expiresAt <= now) {
|
||||
throw createInvalidPasswordResetTokenError("expired", tokenRecord.userId);
|
||||
}
|
||||
|
||||
const oldUser = await tx.user.findUnique({
|
||||
where: {
|
||||
id: tokenRecord.userId,
|
||||
},
|
||||
select: passwordResetAuditSelection,
|
||||
});
|
||||
|
||||
if (!oldUser) {
|
||||
throw createInvalidPasswordResetTokenError("invalid_or_superseded", tokenRecord.userId);
|
||||
}
|
||||
|
||||
const consumedTokenCount = await consumeActiveToken(tokenHash, now, tx);
|
||||
|
||||
if (consumedTokenCount !== 1) {
|
||||
throw createInvalidPasswordResetTokenError("replay", tokenRecord.userId);
|
||||
}
|
||||
|
||||
const updatedUser = await tx.user.update({
|
||||
where: {
|
||||
id: tokenRecord.userId,
|
||||
},
|
||||
data: {
|
||||
password: hashedPassword,
|
||||
},
|
||||
select: passwordResetAuditSelection,
|
||||
});
|
||||
|
||||
return {
|
||||
userId: tokenRecord.userId,
|
||||
oldUser,
|
||||
updatedUser,
|
||||
};
|
||||
});
|
||||
|
||||
const assertResetTokenCanStillBeUsed = async (tokenHash: string, now: Date): Promise<void> => {
|
||||
const tokenRecord = await findByTokenHash(tokenHash);
|
||||
|
||||
if (!tokenRecord) {
|
||||
throw createInvalidPasswordResetTokenError("invalid_or_superseded");
|
||||
}
|
||||
|
||||
if (tokenRecord.expiresAt <= now) {
|
||||
throw createInvalidPasswordResetTokenError("expired", tokenRecord.userId);
|
||||
}
|
||||
};
|
||||
|
||||
const sendPasswordResetNotification = async ({
|
||||
userId,
|
||||
email,
|
||||
locale,
|
||||
}: {
|
||||
userId: string;
|
||||
email: string;
|
||||
locale: TUserLocale;
|
||||
}): Promise<void> => {
|
||||
if (DEBUG_SHOW_RESET_LINK) {
|
||||
logger.info({ userId }, "DEBUG_SHOW_RESET_LINK is enabled; password reset notification delivery skipped");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const didSendNotificationEmail = await sendPasswordResetNotifyEmail({
|
||||
email,
|
||||
locale,
|
||||
});
|
||||
|
||||
assertEmailWasSent(didSendNotificationEmail, new PasswordResetNotificationEmailError());
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
error,
|
||||
stage: "notify_email",
|
||||
userId,
|
||||
},
|
||||
"Failed to send password reset notification email"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const requestPasswordReset = async (
|
||||
user: TPasswordResetRecipient,
|
||||
source: TPasswordResetRequestSource
|
||||
): Promise<void> => {
|
||||
validateInputs(
|
||||
[user.id, ZId],
|
||||
[user.email, ZUserEmail],
|
||||
[user.locale, ZUserLocale],
|
||||
[source, ZPasswordResetSource]
|
||||
);
|
||||
|
||||
const rawToken = crypto.randomBytes(32).toString("base64url");
|
||||
const tokenHash = hashString(rawToken);
|
||||
const expiresAt = getPasswordResetExpiry();
|
||||
const verifyLink = buildPasswordResetLink(rawToken);
|
||||
let tokenIssued = false;
|
||||
|
||||
try {
|
||||
await upsertActiveToken(user.id, tokenHash, expiresAt);
|
||||
tokenIssued = true;
|
||||
await sendPasswordResetLink(user, verifyLink);
|
||||
} catch (error) {
|
||||
logPasswordResetRequestFailure({
|
||||
error,
|
||||
source,
|
||||
stage: tokenIssued ? "send" : "issue",
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
if (tokenIssued) {
|
||||
await revokeIssuedPasswordResetToken(user.id, tokenHash, source);
|
||||
}
|
||||
|
||||
if (source === "profile") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const completePasswordReset = async (
|
||||
rawToken: string,
|
||||
password: string
|
||||
): Promise<{
|
||||
userId: string;
|
||||
oldUser: TPasswordResetAuditUser;
|
||||
updatedUser: TPasswordResetAuditUser;
|
||||
}> => {
|
||||
validateInputs([rawToken, z.string().min(1)], [password, ZUserPassword]);
|
||||
|
||||
if (isLegacyPasswordResetToken(rawToken)) {
|
||||
const error = createInvalidPasswordResetTokenError("legacy_jwt");
|
||||
logPasswordResetTokenRejection(error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const tokenHash = hashString(rawToken);
|
||||
const now = new Date();
|
||||
|
||||
try {
|
||||
await assertResetTokenCanStillBeUsed(tokenHash, now);
|
||||
const hashedPassword = await hashPassword(password);
|
||||
const result = await updatePasswordWithActiveResetToken(tokenHash, hashedPassword, now);
|
||||
await sendPasswordResetNotification({
|
||||
userId: result.userId,
|
||||
email: result.updatedUser.email,
|
||||
locale: result.updatedUser.locale,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidPasswordResetTokenError) {
|
||||
logPasswordResetTokenRejection(error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.error({ error, stage: "password_update" }, "Password reset completion failed");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import {
|
||||
consumeActiveToken,
|
||||
deleteByTokenHash,
|
||||
findByTokenHash,
|
||||
upsertActiveToken,
|
||||
} from "./password-reset-token-repository";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
passwordResetToken: {
|
||||
upsert: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("password-reset-token-repository", () => {
|
||||
const userId = "cm8z6bn2q000008l34h8g7k9m";
|
||||
const mockTokenRecord = {
|
||||
id: "prt_123",
|
||||
userId,
|
||||
tokenHash: "hashed-token",
|
||||
expiresAt: new Date("2026-03-30T12:30:00.000Z"),
|
||||
createdAt: new Date("2026-03-30T12:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-30T12:00:00.000Z"),
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("upserts the active token for a user", async () => {
|
||||
vi.mocked(prisma.passwordResetToken.upsert).mockResolvedValue(mockTokenRecord as any);
|
||||
|
||||
const result = await upsertActiveToken(userId, "hashed-token", mockTokenRecord.expiresAt);
|
||||
|
||||
expect(result).toEqual(mockTokenRecord);
|
||||
expect(prisma.passwordResetToken.upsert).toHaveBeenCalledWith({
|
||||
where: { userId },
|
||||
create: {
|
||||
userId,
|
||||
tokenHash: "hashed-token",
|
||||
expiresAt: mockTokenRecord.expiresAt,
|
||||
},
|
||||
update: {
|
||||
tokenHash: "hashed-token",
|
||||
expiresAt: mockTokenRecord.expiresAt,
|
||||
},
|
||||
select: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
test("finds a token by hash", async () => {
|
||||
vi.mocked(prisma.passwordResetToken.findUnique).mockResolvedValue(mockTokenRecord as any);
|
||||
|
||||
const result = await findByTokenHash("hashed-token");
|
||||
|
||||
expect(result).toEqual(mockTokenRecord);
|
||||
expect(prisma.passwordResetToken.findUnique).toHaveBeenCalledWith({
|
||||
where: { tokenHash: "hashed-token" },
|
||||
select: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
test("deletes by token hash", async () => {
|
||||
vi.mocked(prisma.passwordResetToken.deleteMany).mockResolvedValue({ count: 1 } as any);
|
||||
|
||||
const result = await deleteByTokenHash("hashed-token");
|
||||
|
||||
expect(result).toBe(1);
|
||||
expect(prisma.passwordResetToken.deleteMany).toHaveBeenCalledWith({
|
||||
where: { tokenHash: "hashed-token" },
|
||||
});
|
||||
});
|
||||
|
||||
test("consumes only a non-expired token inside a transaction", async () => {
|
||||
const tx = {
|
||||
passwordResetToken: {
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
} as any;
|
||||
const now = new Date("2026-03-30T12:10:00.000Z");
|
||||
|
||||
const result = await consumeActiveToken("hashed-token", now, tx);
|
||||
|
||||
expect(result).toBe(1);
|
||||
expect(tx.passwordResetToken.deleteMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
tokenHash: "hashed-token",
|
||||
expiresAt: {
|
||||
gt: now,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("wraps prisma known errors in DatabaseError", async () => {
|
||||
vi.mocked(prisma.passwordResetToken.upsert).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("database failed", {
|
||||
code: "P2002",
|
||||
clientVersion: "test",
|
||||
})
|
||||
);
|
||||
|
||||
await expect(upsertActiveToken(userId, "hashed-token", mockTokenRecord.expiresAt)).rejects.toThrow(
|
||||
DatabaseError
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import "server-only";
|
||||
import { Prisma, PrismaClient } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
|
||||
const passwordResetTokenSelection = {
|
||||
id: true,
|
||||
userId: true,
|
||||
tokenHash: true,
|
||||
expiresAt: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
} satisfies Prisma.PasswordResetTokenSelect;
|
||||
|
||||
const ZTokenHash = z.string().min(1);
|
||||
|
||||
type TPasswordResetTokenDbClient = PrismaClient | Prisma.TransactionClient;
|
||||
|
||||
export type TPasswordResetTokenRecord = Prisma.PasswordResetTokenGetPayload<{
|
||||
select: typeof passwordResetTokenSelection;
|
||||
}>;
|
||||
|
||||
const getDbClient = (tx?: Prisma.TransactionClient): TPasswordResetTokenDbClient => tx ?? prisma;
|
||||
|
||||
const handleDatabaseError = (error: unknown): never => {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
};
|
||||
|
||||
export const upsertActiveToken = async (
|
||||
userId: string,
|
||||
tokenHash: string,
|
||||
expiresAt: Date,
|
||||
tx?: Prisma.TransactionClient
|
||||
): Promise<TPasswordResetTokenRecord> => {
|
||||
validateInputs([userId, ZId], [tokenHash, ZTokenHash], [expiresAt, z.date()]);
|
||||
|
||||
try {
|
||||
return await getDbClient(tx).passwordResetToken.upsert({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
tokenHash,
|
||||
expiresAt,
|
||||
},
|
||||
update: {
|
||||
tokenHash,
|
||||
expiresAt,
|
||||
},
|
||||
select: passwordResetTokenSelection,
|
||||
});
|
||||
} catch (error) {
|
||||
return handleDatabaseError(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const findByTokenHash = async (
|
||||
tokenHash: string,
|
||||
tx?: Prisma.TransactionClient
|
||||
): Promise<TPasswordResetTokenRecord | null> => {
|
||||
validateInputs([tokenHash, ZTokenHash]);
|
||||
|
||||
try {
|
||||
return await getDbClient(tx).passwordResetToken.findUnique({
|
||||
where: {
|
||||
tokenHash,
|
||||
},
|
||||
select: passwordResetTokenSelection,
|
||||
});
|
||||
} catch (error) {
|
||||
return handleDatabaseError(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteByTokenHash = async (
|
||||
tokenHash: string,
|
||||
tx?: Prisma.TransactionClient
|
||||
): Promise<number> => {
|
||||
validateInputs([tokenHash, ZTokenHash]);
|
||||
|
||||
try {
|
||||
const result = await getDbClient(tx).passwordResetToken.deleteMany({
|
||||
where: {
|
||||
tokenHash,
|
||||
},
|
||||
});
|
||||
|
||||
return result.count;
|
||||
} catch (error) {
|
||||
return handleDatabaseError(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const consumeActiveToken = async (
|
||||
tokenHash: string,
|
||||
now: Date,
|
||||
tx: Prisma.TransactionClient
|
||||
): Promise<number> => {
|
||||
validateInputs([tokenHash, ZTokenHash], [now, z.date()]);
|
||||
|
||||
try {
|
||||
const result = await tx.passwordResetToken.deleteMany({
|
||||
where: {
|
||||
tokenHash,
|
||||
expiresAt: {
|
||||
gt: now,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return result.count;
|
||||
} catch (error) {
|
||||
return handleDatabaseError(error);
|
||||
}
|
||||
};
|
||||
112
apps/web/modules/auth/forgot-password/reset/actions.test.ts
Normal file
112
apps/web/modules/auth/forgot-password/reset/actions.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE,
|
||||
InvalidPasswordResetTokenError,
|
||||
OperationNotAllowedError,
|
||||
} from "@formbricks/types/errors";
|
||||
import { completePasswordReset } from "@/modules/auth/forgot-password/lib/password-reset-service";
|
||||
import { resetPasswordAction } from "./actions";
|
||||
|
||||
const constantsState = vi.hoisted(() => ({
|
||||
passwordResetDisabled: false,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/forgot-password/lib/password-reset-service", () => ({
|
||||
completePasswordReset: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
get PASSWORD_RESET_DISABLED() {
|
||||
return constantsState.passwordResetDisabled;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
|
||||
withAuditLogging: vi.fn((_event: string, _object: string, fn: Function) => fn),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/action-client", () => ({
|
||||
actionClient: {
|
||||
inputSchema: vi.fn().mockReturnThis(),
|
||||
action: vi.fn((fn) => fn),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("resetPasswordAction", () => {
|
||||
const mockCtx = {
|
||||
auditLoggingCtx: {
|
||||
userId: "",
|
||||
oldObject: null,
|
||||
newObject: null,
|
||||
},
|
||||
};
|
||||
|
||||
const parsedInput = {
|
||||
token: "opaque-reset-token",
|
||||
password: "Password123",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
constantsState.passwordResetDisabled = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("delegates to completePasswordReset and populates audit context on success", async () => {
|
||||
const oldUser = {
|
||||
id: "user_123",
|
||||
email: "user@example.com",
|
||||
locale: "en-US",
|
||||
emailVerified: null,
|
||||
};
|
||||
const updatedUser = {
|
||||
...oldUser,
|
||||
emailVerified: new Date(),
|
||||
};
|
||||
|
||||
vi.mocked(completePasswordReset).mockResolvedValue({
|
||||
userId: "user_123",
|
||||
oldUser,
|
||||
updatedUser,
|
||||
});
|
||||
|
||||
const result = await resetPasswordAction({
|
||||
ctx: mockCtx,
|
||||
parsedInput,
|
||||
} as any);
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(completePasswordReset).toHaveBeenCalledWith(parsedInput.token, parsedInput.password);
|
||||
expect(mockCtx.auditLoggingCtx.userId).toBe("user_123");
|
||||
expect(mockCtx.auditLoggingCtx.oldObject).toEqual({ ...oldUser, passwordResetMarker: false });
|
||||
expect(mockCtx.auditLoggingCtx.newObject).toEqual({ ...updatedUser, passwordResetMarker: true });
|
||||
});
|
||||
|
||||
test("propagates generic invalid password reset failures", async () => {
|
||||
vi.mocked(completePasswordReset).mockRejectedValue(
|
||||
new InvalidPasswordResetTokenError(INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE, "expired")
|
||||
);
|
||||
|
||||
await expect(
|
||||
resetPasswordAction({
|
||||
ctx: mockCtx,
|
||||
parsedInput,
|
||||
} as any)
|
||||
).rejects.toThrow(INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE);
|
||||
});
|
||||
|
||||
test("rejects reset attempts when password reset is disabled", async () => {
|
||||
constantsState.passwordResetDisabled = true;
|
||||
|
||||
await expect(
|
||||
resetPasswordAction({
|
||||
ctx: mockCtx,
|
||||
parsedInput,
|
||||
} as any)
|
||||
).rejects.toThrow(OperationNotAllowedError);
|
||||
expect(completePasswordReset).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,35 +1,30 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import { ZUserPassword } from "@formbricks/types/user";
|
||||
import { hashPassword } from "@/lib/auth";
|
||||
import { verifyToken } from "@/lib/jwt";
|
||||
import { PASSWORD_RESET_DISABLED } from "@/lib/constants";
|
||||
import { actionClient } from "@/lib/utils/action-client";
|
||||
import { getUser, updateUser } from "@/modules/auth/lib/user";
|
||||
import { completePasswordReset } from "@/modules/auth/forgot-password/lib/password-reset-service";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { sendPasswordResetNotifyEmail } from "@/modules/email";
|
||||
|
||||
const ZResetPasswordAction = z.object({
|
||||
token: z.string(),
|
||||
token: z.string().min(1),
|
||||
password: ZUserPassword,
|
||||
});
|
||||
|
||||
export const resetPasswordAction = actionClient.inputSchema(ZResetPasswordAction).action(
|
||||
withAuditLogging("updated", "user", async ({ ctx, parsedInput }) => {
|
||||
const hashedPassword = await hashPassword(parsedInput.password);
|
||||
const { id } = await verifyToken(parsedInput.token);
|
||||
const oldObject = await getUser(id);
|
||||
if (!oldObject) {
|
||||
throw new ResourceNotFoundError("user", id);
|
||||
if (PASSWORD_RESET_DISABLED) {
|
||||
throw new OperationNotAllowedError("Password reset is disabled");
|
||||
}
|
||||
const updatedUser = await updateUser(id, { password: hashedPassword });
|
||||
|
||||
ctx.auditLoggingCtx.userId = id;
|
||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||
ctx.auditLoggingCtx.newObject = updatedUser;
|
||||
const result = await completePasswordReset(parsedInput.token, parsedInput.password);
|
||||
|
||||
ctx.auditLoggingCtx.userId = result.userId;
|
||||
ctx.auditLoggingCtx.oldObject = { ...result.oldUser, passwordResetMarker: false };
|
||||
ctx.auditLoggingCtx.newObject = { ...result.updatedUser, passwordResetMarker: true };
|
||||
|
||||
await sendPasswordResetNotifyEmail({ email: updatedUser.email, locale: updatedUser.locale });
|
||||
return { success: true };
|
||||
})
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { SubmitHandler, useForm } from "react-hook-form";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE } from "@formbricks/types/errors";
|
||||
import { ZUserPassword } from "@formbricks/types/user";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { resetPasswordAction } from "@/modules/auth/forgot-password/reset/actions";
|
||||
@@ -22,7 +23,7 @@ const ZPasswordResetForm = z.object({
|
||||
type TPasswordResetForm = z.infer<typeof ZPasswordResetForm>;
|
||||
|
||||
const passwordInputProps = {
|
||||
autoComplete: "current-password",
|
||||
autoComplete: "new-password",
|
||||
placeholder: "*******",
|
||||
required: true,
|
||||
className:
|
||||
@@ -57,50 +58,50 @@ export const ResetPasswordForm = () => {
|
||||
router.push("/auth/forgot-password/reset/success");
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(resetPasswordResponse);
|
||||
toast.error(errorMessage);
|
||||
toast.error(
|
||||
errorMessage === INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE
|
||||
? t("c.link_expired_description")
|
||||
: errorMessage
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-slate-800">
|
||||
{t("auth.forgot-password.reset.new_password")}
|
||||
</label>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => <PasswordInput {...passwordInputProps} {...field} id="password" />}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-slate-800">
|
||||
{t("auth.forgot-password.reset.confirm_password")}
|
||||
</label>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="confirmPassword"
|
||||
render={({ field }) => (
|
||||
<PasswordInput {...passwordInputProps} {...field} id="confirmPassword" />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PasswordChecks password={form.watch("password")} />
|
||||
</div>
|
||||
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!form.formState.isValid}
|
||||
className="w-full justify-center"
|
||||
loading={form.formState.isSubmitting}>
|
||||
{t("auth.forgot-password.reset_password")}
|
||||
</Button>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-slate-800">
|
||||
{t("auth.forgot-password.reset.new_password")}
|
||||
</label>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => <PasswordInput {...passwordInputProps} {...field} id="password" />}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-slate-800">
|
||||
{t("auth.forgot-password.reset.confirm_password")}
|
||||
</label>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="confirmPassword"
|
||||
render={({ field }) => <PasswordInput {...passwordInputProps} {...field} id="confirmPassword" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PasswordChecks password={form.watch("password")} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!form.formState.isValid}
|
||||
className="w-full justify-center"
|
||||
loading={form.formState.isSubmitting}>
|
||||
{t("auth.forgot-password.reset_password")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,4 +9,6 @@ export const CLOUD_STRIPE_FEATURE_LOOKUP_KEYS = {
|
||||
RBAC: "rbac",
|
||||
SPAM_PROTECTION: "spam-protection",
|
||||
CONTACTS: "contacts",
|
||||
AI_SMART_TOOLS: "ai-smart-tools",
|
||||
AI_DATA_ANALYSIS: "ai-data-analysis",
|
||||
} as const;
|
||||
|
||||
@@ -146,7 +146,8 @@ describe("License Core Logic", () => {
|
||||
sso: true,
|
||||
saml: true,
|
||||
spamProtection: true,
|
||||
ai: false,
|
||||
aiSmartTools: false,
|
||||
aiDataAnalysis: false,
|
||||
auditLogs: true,
|
||||
accessControl: true,
|
||||
quotas: true,
|
||||
@@ -281,7 +282,8 @@ describe("License Core Logic", () => {
|
||||
whitelabel: false,
|
||||
removeBranding: false,
|
||||
contacts: false,
|
||||
ai: false,
|
||||
aiSmartTools: false,
|
||||
aiDataAnalysis: false,
|
||||
saml: false,
|
||||
spamProtection: false,
|
||||
auditLogs: false,
|
||||
@@ -302,7 +304,8 @@ describe("License Core Logic", () => {
|
||||
whitelabel: false,
|
||||
removeBranding: false,
|
||||
contacts: false,
|
||||
ai: false,
|
||||
aiSmartTools: false,
|
||||
aiDataAnalysis: false,
|
||||
saml: false,
|
||||
spamProtection: false,
|
||||
auditLogs: false,
|
||||
@@ -332,7 +335,8 @@ describe("License Core Logic", () => {
|
||||
whitelabel: false,
|
||||
removeBranding: false,
|
||||
contacts: false,
|
||||
ai: false,
|
||||
aiSmartTools: false,
|
||||
aiDataAnalysis: false,
|
||||
saml: false,
|
||||
spamProtection: false,
|
||||
auditLogs: false,
|
||||
@@ -521,7 +525,8 @@ describe("License Core Logic", () => {
|
||||
sso: true,
|
||||
saml: true,
|
||||
spamProtection: true,
|
||||
ai: false,
|
||||
aiSmartTools: false,
|
||||
aiDataAnalysis: false,
|
||||
auditLogs: true,
|
||||
accessControl: true,
|
||||
quotas: true,
|
||||
@@ -585,7 +590,8 @@ describe("License Core Logic", () => {
|
||||
sso: true,
|
||||
saml: true,
|
||||
spamProtection: true,
|
||||
ai: false,
|
||||
aiSmartTools: false,
|
||||
aiDataAnalysis: false,
|
||||
auditLogs: true,
|
||||
accessControl: true,
|
||||
quotas: true,
|
||||
@@ -640,7 +646,8 @@ describe("License Core Logic", () => {
|
||||
sso: true,
|
||||
saml: true,
|
||||
spamProtection: true,
|
||||
ai: false,
|
||||
aiSmartTools: false,
|
||||
aiDataAnalysis: false,
|
||||
auditLogs: true,
|
||||
accessControl: true,
|
||||
quotas: true,
|
||||
@@ -782,7 +789,8 @@ describe("License Core Logic", () => {
|
||||
sso: true,
|
||||
saml: true,
|
||||
spamProtection: true,
|
||||
ai: true,
|
||||
aiSmartTools: true,
|
||||
aiDataAnalysis: true,
|
||||
auditLogs: true,
|
||||
accessControl: true,
|
||||
quotas: true,
|
||||
@@ -810,7 +818,8 @@ describe("License Core Logic", () => {
|
||||
sso: true,
|
||||
saml: true,
|
||||
spamProtection: true,
|
||||
ai: true,
|
||||
aiSmartTools: true,
|
||||
aiDataAnalysis: true,
|
||||
auditLogs: true,
|
||||
accessControl: true,
|
||||
quotas: true,
|
||||
@@ -839,7 +848,8 @@ describe("License Core Logic", () => {
|
||||
whitelabel: false,
|
||||
removeBranding: false,
|
||||
contacts: false,
|
||||
ai: false,
|
||||
aiSmartTools: false,
|
||||
aiDataAnalysis: false,
|
||||
saml: false,
|
||||
spamProtection: false,
|
||||
auditLogs: false,
|
||||
@@ -910,7 +920,8 @@ describe("License Core Logic", () => {
|
||||
whitelabel: true,
|
||||
removeBranding: true,
|
||||
contacts: true,
|
||||
ai: true,
|
||||
aiSmartTools: true,
|
||||
aiDataAnalysis: true,
|
||||
saml: true,
|
||||
spamProtection: true,
|
||||
auditLogs: true,
|
||||
@@ -978,7 +989,8 @@ describe("License Core Logic", () => {
|
||||
whitelabel: true,
|
||||
removeBranding: true,
|
||||
contacts: true,
|
||||
ai: true,
|
||||
aiSmartTools: true,
|
||||
aiDataAnalysis: true,
|
||||
saml: true,
|
||||
spamProtection: true,
|
||||
auditLogs: true,
|
||||
@@ -1020,7 +1032,8 @@ describe("License Core Logic", () => {
|
||||
sso: true,
|
||||
saml: true,
|
||||
spamProtection: true,
|
||||
ai: false,
|
||||
aiSmartTools: false,
|
||||
aiDataAnalysis: false,
|
||||
auditLogs: true,
|
||||
accessControl: true,
|
||||
quotas: true,
|
||||
@@ -1146,7 +1159,8 @@ describe("License Core Logic", () => {
|
||||
sso: true,
|
||||
saml: true,
|
||||
spamProtection: true,
|
||||
ai: false,
|
||||
aiSmartTools: false,
|
||||
aiDataAnalysis: false,
|
||||
auditLogs: true,
|
||||
accessControl: true,
|
||||
quotas: true,
|
||||
@@ -1267,7 +1281,8 @@ describe("License Core Logic", () => {
|
||||
whitelabel: true,
|
||||
removeBranding: true,
|
||||
contacts: true,
|
||||
ai: true,
|
||||
aiSmartTools: true,
|
||||
aiDataAnalysis: true,
|
||||
saml: true,
|
||||
spamProtection: true,
|
||||
auditLogs: true,
|
||||
@@ -1322,7 +1337,8 @@ describe("License Core Logic", () => {
|
||||
whitelabel: true,
|
||||
removeBranding: true,
|
||||
contacts: true,
|
||||
ai: true,
|
||||
aiSmartTools: true,
|
||||
aiDataAnalysis: true,
|
||||
saml: true,
|
||||
spamProtection: true,
|
||||
auditLogs: true,
|
||||
@@ -1377,7 +1393,8 @@ describe("License Core Logic", () => {
|
||||
whitelabel: true,
|
||||
removeBranding: true,
|
||||
contacts: true,
|
||||
ai: true,
|
||||
aiSmartTools: true,
|
||||
aiDataAnalysis: true,
|
||||
saml: true,
|
||||
spamProtection: true,
|
||||
auditLogs: true,
|
||||
|
||||
@@ -77,7 +77,8 @@ const LicenseFeaturesSchema = z.object({
|
||||
whitelabel: z.boolean(),
|
||||
removeBranding: z.boolean(),
|
||||
contacts: z.boolean(),
|
||||
ai: z.boolean(),
|
||||
aiSmartTools: z.boolean(),
|
||||
aiDataAnalysis: z.boolean(),
|
||||
saml: z.boolean(),
|
||||
spamProtection: z.boolean(),
|
||||
auditLogs: z.boolean(),
|
||||
@@ -144,7 +145,8 @@ const DEFAULT_FEATURES: TEnterpriseLicenseFeatures = {
|
||||
whitelabel: false,
|
||||
removeBranding: false,
|
||||
contacts: false,
|
||||
ai: false,
|
||||
aiSmartTools: false,
|
||||
aiDataAnalysis: false,
|
||||
saml: false,
|
||||
spamProtection: false,
|
||||
auditLogs: false,
|
||||
|
||||
@@ -9,6 +9,8 @@ import { getEnterpriseLicense, getLicenseFeatures } from "./license";
|
||||
import {
|
||||
getAccessControlPermission,
|
||||
getBiggerUploadFileSizePermission,
|
||||
getIsAIDataAnalysisEnabled,
|
||||
getIsAISmartToolsEnabled,
|
||||
getIsAuditLogsEnabled,
|
||||
getIsContactsEnabled,
|
||||
getIsMultiOrgEnabled,
|
||||
@@ -55,7 +57,8 @@ const defaultFeatures: TEnterpriseLicenseFeatures = {
|
||||
sso: false,
|
||||
saml: false,
|
||||
spamProtection: false,
|
||||
ai: false,
|
||||
aiSmartTools: false,
|
||||
aiDataAnalysis: false,
|
||||
auditLogs: false,
|
||||
accessControl: false,
|
||||
quotas: false,
|
||||
@@ -194,6 +197,72 @@ describe("License Utils", () => {
|
||||
expect(access).toBe(true);
|
||||
expect(quotas).toBe(true);
|
||||
});
|
||||
|
||||
test("uses cloud AI smart tools entitlement", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
|
||||
vi.mocked(hasOrganizationEntitlementWithLicenseGuard).mockResolvedValueOnce(true);
|
||||
|
||||
const result = await getIsAISmartToolsEnabled("org_1");
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(hasOrganizationEntitlementWithLicenseGuard).toHaveBeenCalledWith(
|
||||
"org_1",
|
||||
CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.AI_SMART_TOOLS
|
||||
);
|
||||
});
|
||||
|
||||
test("uses cloud AI data analysis entitlement", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
|
||||
vi.mocked(hasOrganizationEntitlementWithLicenseGuard).mockResolvedValueOnce(true);
|
||||
|
||||
const result = await getIsAIDataAnalysisEnabled("org_1");
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(hasOrganizationEntitlementWithLicenseGuard).toHaveBeenCalledWith(
|
||||
"org_1",
|
||||
CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.AI_DATA_ANALYSIS
|
||||
);
|
||||
});
|
||||
|
||||
test("returns self-hosted AI features from license", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = false;
|
||||
vi.mocked(getEnterpriseLicense).mockResolvedValue({
|
||||
...defaultLicense,
|
||||
features: {
|
||||
...defaultFeatures,
|
||||
aiSmartTools: true,
|
||||
aiDataAnalysis: true,
|
||||
},
|
||||
});
|
||||
|
||||
const [smartTools, dataAnalysis] = await Promise.all([
|
||||
getIsAISmartToolsEnabled("org_1"),
|
||||
getIsAIDataAnalysisEnabled("org_1"),
|
||||
]);
|
||||
|
||||
expect(smartTools).toBe(true);
|
||||
expect(dataAnalysis).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for self-hosted AI features when not enabled", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = false;
|
||||
vi.mocked(getEnterpriseLicense).mockResolvedValue({
|
||||
...defaultLicense,
|
||||
features: {
|
||||
...defaultFeatures,
|
||||
aiSmartTools: false,
|
||||
aiDataAnalysis: false,
|
||||
},
|
||||
});
|
||||
|
||||
const [smartTools, dataAnalysis] = await Promise.all([
|
||||
getIsAISmartToolsEnabled("org_1"),
|
||||
getIsAIDataAnalysisEnabled("org_1"),
|
||||
]);
|
||||
|
||||
expect(smartTools).toBe(false);
|
||||
expect(dataAnalysis).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBiggerUploadFileSizePermission", () => {
|
||||
|
||||
@@ -29,13 +29,18 @@ const getFeaturePermission = async (
|
||||
// On Self-hosted: requires active license AND feature enabled in license
|
||||
const getCustomPlanFeaturePermission = async (
|
||||
organizationId: string,
|
||||
featureKey: keyof Pick<TEnterpriseLicenseFeatures, "accessControl" | "quotas" | "contacts">
|
||||
featureKey: keyof Pick<
|
||||
TEnterpriseLicenseFeatures,
|
||||
"accessControl" | "quotas" | "contacts" | "aiSmartTools" | "aiDataAnalysis"
|
||||
>
|
||||
): Promise<boolean> => {
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
const featureLookupKeyMap: Record<string, string> = {
|
||||
accessControl: CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.RBAC,
|
||||
quotas: CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.QUOTA_MANAGEMENT,
|
||||
contacts: CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.CONTACTS,
|
||||
aiSmartTools: CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.AI_SMART_TOOLS,
|
||||
aiDataAnalysis: CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.AI_DATA_ANALYSIS,
|
||||
};
|
||||
const lookupKey = featureLookupKeyMap[featureKey];
|
||||
if (lookupKey) {
|
||||
@@ -109,6 +114,14 @@ export const getIsQuotasEnabled = async (organizationId: string): Promise<boolea
|
||||
return getCustomPlanFeaturePermission(organizationId, "quotas");
|
||||
};
|
||||
|
||||
export const getIsAISmartToolsEnabled = async (organizationId: string): Promise<boolean> => {
|
||||
return getCustomPlanFeaturePermission(organizationId, "aiSmartTools");
|
||||
};
|
||||
|
||||
export const getIsAIDataAnalysisEnabled = async (organizationId: string): Promise<boolean> => {
|
||||
return getCustomPlanFeaturePermission(organizationId, "aiDataAnalysis");
|
||||
};
|
||||
|
||||
export const getIsAuditLogsEnabled = async (): Promise<boolean> => {
|
||||
if (!AUDIT_LOG_ENABLED) return false;
|
||||
return getSpecificFeatureFlag("auditLogs");
|
||||
|
||||
@@ -14,7 +14,8 @@ const ZEnterpriseLicenseFeatures = z.object({
|
||||
sso: z.boolean(),
|
||||
saml: z.boolean(),
|
||||
spamProtection: z.boolean(),
|
||||
ai: z.boolean(),
|
||||
aiSmartTools: z.boolean(),
|
||||
aiDataAnalysis: z.boolean(),
|
||||
auditLogs: z.boolean(),
|
||||
accessControl: z.boolean(),
|
||||
quotas: z.boolean(),
|
||||
|
||||
@@ -37,7 +37,8 @@ describe("getFirstOrganization", () => {
|
||||
projects: 3,
|
||||
},
|
||||
},
|
||||
isAIEnabled: false,
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
};
|
||||
vi.mocked(prisma.organization.findFirst).mockResolvedValue(org);
|
||||
const result = await getFirstOrganization();
|
||||
|
||||
@@ -45,7 +45,8 @@ export const mockSamlAccount: Account = {
|
||||
export const mockOrganization: TOrganization = {
|
||||
id: "org-123",
|
||||
name: "Test Organization",
|
||||
isAIEnabled: false,
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
whitelabel: {
|
||||
logoUrl: null,
|
||||
faviconUrl: null,
|
||||
|
||||
34
apps/web/modules/email/forgot-password-email.test.ts
Normal file
34
apps/web/modules/email/forgot-password-email.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { renderForgotPasswordEmail } from "@formbricks/email";
|
||||
|
||||
const t = (key: string, replacements?: Record<string, string>): string => {
|
||||
if (key === "emails.forgot_password_email_link_valid_for_24_hours") {
|
||||
return `The link is valid for ${replacements?.minutes} minutes.`;
|
||||
}
|
||||
|
||||
const translations: Record<string, string> = {
|
||||
"emails.forgot_password_email_heading": "Change password",
|
||||
"emails.forgot_password_email_text":
|
||||
"You have requested a link to change your password. You can do this by clicking the link below:",
|
||||
"emails.forgot_password_email_change_password": "Change password",
|
||||
"emails.forgot_password_email_did_not_request": "If you didn't request this, please ignore this email.",
|
||||
"emails.email_footer_text_1": "Have a great day!",
|
||||
"emails.email_footer_text_2": "The Formbricks Team",
|
||||
"emails.email_template_text_1": "This email was sent via Formbricks.",
|
||||
};
|
||||
|
||||
return translations[key] ?? key;
|
||||
};
|
||||
|
||||
describe("renderForgotPasswordEmail", () => {
|
||||
test("renders the configurable link lifetime in minutes", async () => {
|
||||
const html = await renderForgotPasswordEmail({
|
||||
verifyLink: "https://app.formbricks.com/auth/forgot-password/reset?token=test-token",
|
||||
linkValidityInMinutes: 30,
|
||||
t,
|
||||
});
|
||||
|
||||
expect(html).toContain("The link is valid for 30 minutes.");
|
||||
expect(html).not.toContain("24 hours");
|
||||
});
|
||||
});
|
||||
@@ -157,17 +157,19 @@ export const sendVerificationEmail = async ({
|
||||
}
|
||||
};
|
||||
|
||||
export const sendForgotPasswordEmail = async (user: {
|
||||
id: string;
|
||||
export const sendPasswordResetLinkEmail = async (user: {
|
||||
email: TUserEmail;
|
||||
locale: TUserLocale;
|
||||
verifyLink: string;
|
||||
linkValidityInMinutes: number;
|
||||
}): Promise<boolean> => {
|
||||
const t = await getTranslate(user.locale);
|
||||
const token = createToken(user.id, {
|
||||
expiresIn: "1d",
|
||||
const html = await renderForgotPasswordEmail({
|
||||
verifyLink: user.verifyLink,
|
||||
linkValidityInMinutes: user.linkValidityInMinutes,
|
||||
t,
|
||||
...legalProps,
|
||||
});
|
||||
const verifyLink = `${WEBAPP_URL}/auth/forgot-password/reset?token=${encodeURIComponent(token)}`;
|
||||
const html = await renderForgotPasswordEmail({ verifyLink, t, ...legalProps });
|
||||
return await sendEmail({
|
||||
to: user.email,
|
||||
subject: t("emails.forgot_password_email_subject"),
|
||||
|
||||
@@ -125,6 +125,46 @@ describe("hasOrganizationEntitlementWithLicenseGuard", () => {
|
||||
expect(await hasOrganizationEntitlementWithLicenseGuard("org1", "rbac")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns true when license active and ai-smart-tools mapped feature enabled", async () => {
|
||||
mockGetContext.mockResolvedValue({
|
||||
...baseContext,
|
||||
features: ["ai-smart-tools"],
|
||||
licenseStatus: "active",
|
||||
licenseFeatures: { aiSmartTools: true } as TOrganizationEntitlementsContext["licenseFeatures"],
|
||||
});
|
||||
expect(await hasOrganizationEntitlementWithLicenseGuard("org1", "ai-smart-tools")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false when license active but ai-smart-tools mapped feature disabled", async () => {
|
||||
mockGetContext.mockResolvedValue({
|
||||
...baseContext,
|
||||
features: ["ai-smart-tools"],
|
||||
licenseStatus: "active",
|
||||
licenseFeatures: { aiSmartTools: false } as TOrganizationEntitlementsContext["licenseFeatures"],
|
||||
});
|
||||
expect(await hasOrganizationEntitlementWithLicenseGuard("org1", "ai-smart-tools")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns true when license active and ai-data-analysis mapped feature enabled", async () => {
|
||||
mockGetContext.mockResolvedValue({
|
||||
...baseContext,
|
||||
features: ["ai-data-analysis"],
|
||||
licenseStatus: "active",
|
||||
licenseFeatures: { aiDataAnalysis: true } as TOrganizationEntitlementsContext["licenseFeatures"],
|
||||
});
|
||||
expect(await hasOrganizationEntitlementWithLicenseGuard("org1", "ai-data-analysis")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false when license active but ai-data-analysis mapped feature disabled", async () => {
|
||||
mockGetContext.mockResolvedValue({
|
||||
...baseContext,
|
||||
features: ["ai-data-analysis"],
|
||||
licenseStatus: "active",
|
||||
licenseFeatures: { aiDataAnalysis: false } as TOrganizationEntitlementsContext["licenseFeatures"],
|
||||
});
|
||||
expect(await hasOrganizationEntitlementWithLicenseGuard("org1", "ai-data-analysis")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns true when license active and feature has no license mapping", async () => {
|
||||
mockGetContext.mockResolvedValue({
|
||||
...baseContext,
|
||||
|
||||
@@ -10,6 +10,8 @@ const LICENSE_GUARDED_ENTITLEMENTS: Partial<Record<string, keyof TEnterpriseLice
|
||||
rbac: "accessControl",
|
||||
"spam-protection": "spamProtection",
|
||||
contacts: "contacts",
|
||||
"ai-smart-tools": "aiSmartTools",
|
||||
"ai-data-analysis": "aiDataAnalysis",
|
||||
};
|
||||
|
||||
const TRIAL_RESTRICTED_ENTITLEMENT_KEYS = [
|
||||
|
||||
@@ -99,4 +99,46 @@ describe("getSelfHostedOrganizationEntitlementsContext", () => {
|
||||
|
||||
expect(result.features).toContain("hide-branding");
|
||||
});
|
||||
|
||||
test("maps aiSmartTools feature to ai-smart-tools entitlement", async () => {
|
||||
mockGetOrg.mockResolvedValue({ id: "org1" } as any);
|
||||
mockGetLicense.mockResolvedValue({
|
||||
status: "active",
|
||||
active: true,
|
||||
features: { aiSmartTools: true },
|
||||
} as any);
|
||||
|
||||
const result = await getSelfHostedOrganizationEntitlementsContext("org1");
|
||||
|
||||
expect(result.features).toContain("ai-smart-tools");
|
||||
expect(result.features).not.toContain("ai-data-analysis");
|
||||
});
|
||||
|
||||
test("maps aiDataAnalysis feature to ai-data-analysis entitlement", async () => {
|
||||
mockGetOrg.mockResolvedValue({ id: "org1" } as any);
|
||||
mockGetLicense.mockResolvedValue({
|
||||
status: "active",
|
||||
active: true,
|
||||
features: { aiDataAnalysis: true },
|
||||
} as any);
|
||||
|
||||
const result = await getSelfHostedOrganizationEntitlementsContext("org1");
|
||||
|
||||
expect(result.features).toContain("ai-data-analysis");
|
||||
expect(result.features).not.toContain("ai-smart-tools");
|
||||
});
|
||||
|
||||
test("maps both AI features when both are enabled", async () => {
|
||||
mockGetOrg.mockResolvedValue({ id: "org1" } as any);
|
||||
mockGetLicense.mockResolvedValue({
|
||||
status: "active",
|
||||
active: true,
|
||||
features: { aiSmartTools: true, aiDataAnalysis: true },
|
||||
} as any);
|
||||
|
||||
const result = await getSelfHostedOrganizationEntitlementsContext("org1");
|
||||
|
||||
expect(result.features).toContain("ai-smart-tools");
|
||||
expect(result.features).toContain("ai-data-analysis");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,12 @@ const mapLicenseFeaturesToEntitlements = (
|
||||
if (features.contacts) {
|
||||
entitlementKeys.push(CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.CONTACTS);
|
||||
}
|
||||
if (features.aiSmartTools) {
|
||||
entitlementKeys.push(CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.AI_SMART_TOOLS);
|
||||
}
|
||||
if (features.aiDataAnalysis) {
|
||||
entitlementKeys.push(CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.AI_DATA_ANALYSIS);
|
||||
}
|
||||
|
||||
return entitlementKeys;
|
||||
};
|
||||
|
||||
@@ -277,7 +277,8 @@ describe("utils.ts", () => {
|
||||
updatedAt: new Date("2024-01-02"),
|
||||
name: "Test Organization",
|
||||
billing: { stripeCustomerId: null, limits: {}, usageCycleAnchor: new Date() },
|
||||
isAIEnabled: false,
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
whitelabel: false,
|
||||
memberships: [
|
||||
{
|
||||
@@ -417,7 +418,8 @@ describe("utils.ts", () => {
|
||||
updatedAt: new Date("2024-01-02"),
|
||||
name: "Test Organization",
|
||||
billing: { stripeCustomerId: null, limits: {}, usageCycleAnchor: new Date() },
|
||||
isAIEnabled: false,
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
whitelabel: false,
|
||||
memberships: [
|
||||
{
|
||||
@@ -527,7 +529,8 @@ describe("utils.ts", () => {
|
||||
updatedAt: new Date("2024-01-02"),
|
||||
name: "Test Organization",
|
||||
billing: null,
|
||||
isAIEnabled: false,
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
whitelabel: false,
|
||||
memberships: [
|
||||
{
|
||||
@@ -577,7 +580,8 @@ describe("utils.ts", () => {
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
billing: { stripeCustomerId: null, limits: {}, usageCycleAnchor: new Date() },
|
||||
isAIEnabled: false,
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
whitelabel: false,
|
||||
memberships: [], // No membership
|
||||
},
|
||||
@@ -660,7 +664,8 @@ describe("utils.ts", () => {
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
billing: { stripeCustomerId: null, limits: {}, usageCycleAnchor: new Date() },
|
||||
isAIEnabled: false,
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
whitelabel: false,
|
||||
memberships: [{ userId: "user123", organizationId: "org123", role: "owner", accepted: true }],
|
||||
},
|
||||
@@ -699,7 +704,8 @@ describe("utils.ts", () => {
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
billing: { stripeCustomerId: null, limits: {}, usageCycleAnchor: new Date() },
|
||||
isAIEnabled: true,
|
||||
isAISmartToolsEnabled: true,
|
||||
isAIDataAnalysisEnabled: true,
|
||||
whitelabel: true,
|
||||
memberships: [{ userId: "user123", organizationId: "org456", role: "member", accepted: true }],
|
||||
},
|
||||
|
||||
@@ -184,7 +184,8 @@ export const getEnvironmentWithRelations = reactCache(async (environmentId: stri
|
||||
stripe: true,
|
||||
},
|
||||
},
|
||||
isAIEnabled: true,
|
||||
isAISmartToolsEnabled: true,
|
||||
isAIDataAnalysisEnabled: true,
|
||||
whitelabel: true,
|
||||
// Current user's membership only (filtered at DB level)
|
||||
memberships: {
|
||||
@@ -247,7 +248,8 @@ export const getEnvironmentWithRelations = reactCache(async (environmentId: stri
|
||||
updatedAt: data.project.organization.updatedAt,
|
||||
name: data.project.organization.name,
|
||||
billing: data.project.organization.billing,
|
||||
isAIEnabled: data.project.organization.isAIEnabled,
|
||||
isAISmartToolsEnabled: data.project.organization.isAISmartToolsEnabled,
|
||||
isAIDataAnalysisEnabled: data.project.organization.isAIDataAnalysisEnabled,
|
||||
whitelabel: data.project.organization.whitelabel,
|
||||
},
|
||||
environments: data.project.environments,
|
||||
|
||||
@@ -72,6 +72,7 @@ export const SurveyMenuBar = ({
|
||||
const [lastAutoSaved, setLastAutoSaved] = useState<Date | null>(null);
|
||||
const isSuccessfullySavedRef = useRef(false);
|
||||
const isAutoSavingRef = useRef(false);
|
||||
const isSurveyPublishingRef = useRef(false);
|
||||
|
||||
// Refs for interval-based auto-save (to access current values without re-creating interval)
|
||||
const localSurveyRef = useRef(localSurvey);
|
||||
@@ -269,8 +270,8 @@ export const SurveyMenuBar = ({
|
||||
// Skip if tab is not visible (no computation, no API calls for background tabs)
|
||||
if (document.hidden) return;
|
||||
|
||||
// Skip if already saving (manual or auto)
|
||||
if (isAutoSavingRef.current || isSurveySavingRef.current) return;
|
||||
// Skip if already saving, publishing, or auto-saving
|
||||
if (isAutoSavingRef.current || isSurveySavingRef.current || isSurveyPublishingRef.current) return;
|
||||
|
||||
// Check for changes using refs (avoids re-creating interval on every change)
|
||||
const { updatedAt: localUpdatedAt, ...localSurveyRest } = localSurveyRef.current;
|
||||
@@ -289,10 +290,19 @@ export const SurveyMenuBar = ({
|
||||
} as unknown as TSurveyDraft);
|
||||
|
||||
if (updatedSurveyResponse?.data) {
|
||||
const savedData = updatedSurveyResponse.data;
|
||||
|
||||
// If the segment changed on the server (e.g., private segment was deleted when
|
||||
// switching from app to link type), update localSurvey to prevent stale segment
|
||||
// references when publishing
|
||||
if (!isEqual(localSurveyRef.current.segment, savedData.segment)) {
|
||||
setLocalSurvey({ ...localSurveyRef.current, segment: savedData.segment });
|
||||
}
|
||||
|
||||
// Update surveyRef (not localSurvey state) to prevent re-renders during auto-save.
|
||||
// This keeps the UI stable while still tracking that changes have been saved.
|
||||
// The comparison uses refs, so this prevents unnecessary re-saves.
|
||||
surveyRef.current = { ...updatedSurveyResponse.data };
|
||||
surveyRef.current = { ...savedData };
|
||||
isSuccessfullySavedRef.current = true;
|
||||
setLastAutoSaved(new Date());
|
||||
}
|
||||
@@ -417,11 +427,13 @@ export const SurveyMenuBar = ({
|
||||
};
|
||||
|
||||
const handleSurveyPublish = async () => {
|
||||
isSurveyPublishingRef.current = true;
|
||||
setIsSurveyPublishing(true);
|
||||
|
||||
const isSurveyValidatedWithZod = validateSurveyWithZod();
|
||||
|
||||
if (!isSurveyValidatedWithZod) {
|
||||
isSurveyPublishingRef.current = false;
|
||||
setIsSurveyPublishing(false);
|
||||
return;
|
||||
}
|
||||
@@ -429,6 +441,7 @@ export const SurveyMenuBar = ({
|
||||
try {
|
||||
const isSurveyValidResult = isSurveyValid(localSurvey, selectedLanguageCode, t, responseCount);
|
||||
if (!isSurveyValidResult) {
|
||||
isSurveyPublishingRef.current = false;
|
||||
setIsSurveyPublishing(false);
|
||||
return;
|
||||
}
|
||||
@@ -445,10 +458,12 @@ export const SurveyMenuBar = ({
|
||||
if (!publishResult?.data) {
|
||||
const errorMessage = getFormattedErrorMessage(publishResult);
|
||||
toast.error(errorMessage);
|
||||
isSurveyPublishingRef.current = false;
|
||||
setIsSurveyPublishing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
isSurveyPublishingRef.current = false;
|
||||
setIsSurveyPublishing(false);
|
||||
// Set flag to prevent beforeunload warning during navigation
|
||||
isSuccessfullySavedRef.current = true;
|
||||
@@ -456,6 +471,7 @@ export const SurveyMenuBar = ({
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(t("environments.surveys.edit.error_publishing_survey"));
|
||||
isSurveyPublishingRef.current = false;
|
||||
setIsSurveyPublishing(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -149,7 +149,8 @@ describe("Survey Editor Library Tests", () => {
|
||||
features: {},
|
||||
usageCycleAnchor: new Date(),
|
||||
},
|
||||
isAIEnabled: false,
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -83,8 +83,13 @@ describe("getOrganizationAIKeys", () => {
|
||||
});
|
||||
|
||||
const mockOrgId = "org_test789";
|
||||
const mockOrganizationData: { isAIEnabled: boolean; billing: TOrganizationBilling } = {
|
||||
isAIEnabled: true,
|
||||
const mockOrganizationData: {
|
||||
isAISmartToolsEnabled: boolean;
|
||||
isAIDataAnalysisEnabled: boolean;
|
||||
billing: TOrganizationBilling;
|
||||
} = {
|
||||
isAISmartToolsEnabled: true,
|
||||
isAIDataAnalysisEnabled: true,
|
||||
billing: {
|
||||
stripeCustomerId: null,
|
||||
usageCycleAnchor: new Date(),
|
||||
@@ -106,7 +111,8 @@ describe("getOrganizationAIKeys", () => {
|
||||
id: mockOrgId,
|
||||
},
|
||||
select: {
|
||||
isAIEnabled: true,
|
||||
isAISmartToolsEnabled: true,
|
||||
isAIDataAnalysisEnabled: true,
|
||||
billing: {
|
||||
select: {
|
||||
stripeCustomerId: true,
|
||||
|
||||
@@ -30,14 +30,21 @@ export const getOrganizationIdFromEnvironmentId = reactCache(
|
||||
);
|
||||
|
||||
export const getOrganizationAIKeys = reactCache(
|
||||
async (organizationId: string): Promise<{ isAIEnabled: boolean; billing: TOrganizationBilling } | null> => {
|
||||
async (
|
||||
organizationId: string
|
||||
): Promise<{
|
||||
isAISmartToolsEnabled: boolean;
|
||||
isAIDataAnalysisEnabled: boolean;
|
||||
billing: TOrganizationBilling;
|
||||
} | null> => {
|
||||
try {
|
||||
const organization = await prisma.organization.findUnique({
|
||||
where: {
|
||||
id: organizationId,
|
||||
},
|
||||
select: {
|
||||
isAIEnabled: true,
|
||||
isAISmartToolsEnabled: true,
|
||||
isAIDataAnalysisEnabled: true,
|
||||
billing: {
|
||||
select: {
|
||||
stripeCustomerId: true,
|
||||
@@ -54,7 +61,8 @@ export const getOrganizationAIKeys = reactCache(
|
||||
}
|
||||
|
||||
return {
|
||||
isAIEnabled: organization.isAIEnabled,
|
||||
isAISmartToolsEnabled: organization.isAISmartToolsEnabled,
|
||||
isAIDataAnalysisEnabled: organization.isAIDataAnalysisEnabled,
|
||||
billing: {
|
||||
stripeCustomerId: organization.billing.stripeCustomerId,
|
||||
limits: organization.billing.limits as TOrganizationBilling["limits"],
|
||||
|
||||
@@ -202,7 +202,7 @@ function getLanguageCode(langParam: string | undefined, survey: TSurvey): string
|
||||
|
||||
const selectedLanguage = survey.languages.find((surveyLanguage) => {
|
||||
return (
|
||||
surveyLanguage.language.code === langParam.toLowerCase() ||
|
||||
surveyLanguage.language.code.toLowerCase() === langParam.toLowerCase() ||
|
||||
surveyLanguage.language.alias?.toLowerCase() === langParam.toLowerCase()
|
||||
);
|
||||
});
|
||||
|
||||
@@ -200,7 +200,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
OIDC_ISSUER: "test-oidc-issuer",
|
||||
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
WEBAPP_URL: "https://test-webapp-url.com",
|
||||
STRIPE_API_VERSION: "2026-01-28.clover",
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
@@ -240,5 +240,6 @@ vi.mock("@/lib/constants", () => ({
|
||||
MAIL_FROM: "mock@mail.com",
|
||||
MAIL_FROM_NAME: "Mock Mail",
|
||||
RATE_LIMITING_DISABLED: false,
|
||||
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: 30,
|
||||
CONTROL_HASH: "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q",
|
||||
}));
|
||||
|
||||
@@ -30,6 +30,7 @@ These variables are present inside your machine's docker-compose file. Restart t
|
||||
| IMPRINT_ADDRESS | Address for imprint. | optional | |
|
||||
| EMAIL_AUTH_DISABLED | Disables the ability for users to signup or login via email and password if set to 1. | optional | |
|
||||
| PASSWORD_RESET_DISABLED | Disables password reset functionality if set to 1. | optional | |
|
||||
| PASSWORD_RESET_TOKEN_LIFETIME_MINUTES | Configures how long password reset links remain valid in minutes. Accepted values are integers from 5 to 120. | optional | 30 |
|
||||
| EMAIL_VERIFICATION_DISABLED | Disables email verification if set to 1. | optional | |
|
||||
| RATE_LIMITING_DISABLED | Disables rate limiting if set to 1. | optional | |
|
||||
| DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS | Allows webhook URLs to point to internal/private network addresses (e.g. localhost, 192.168.x.x) if set to 1. Useful for self-hosted instances that need to send webhooks to internal services. | optional | |
|
||||
|
||||
@@ -64,6 +64,9 @@ EMAIL_VERIFICATION_DISABLED=0
|
||||
|
||||
# Set to 0 to enable password reset functionality (requires working SMTP)
|
||||
PASSWORD_RESET_DISABLED=0
|
||||
|
||||
# Optional: configure the password reset link lifetime in minutes (5-120, default 30)
|
||||
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES=30
|
||||
```
|
||||
|
||||
## Configuration for One-Click Setup
|
||||
@@ -83,6 +86,7 @@ environment:
|
||||
SMTP_PASSWORD: your_password
|
||||
EMAIL_VERIFICATION_DISABLED: 0
|
||||
PASSWORD_RESET_DISABLED: 0
|
||||
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: 30
|
||||
```
|
||||
|
||||
2. Or during the setup, answer "Yes" when prompted to set up the email service:
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Step 1: Add new columns
|
||||
ALTER TABLE "Organization" ADD COLUMN "isAISmartToolsEnabled" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "Organization" ADD COLUMN "isAIDataAnalysisEnabled" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- Step 2: Migrate existing data -- organizations that had AI enabled get both new flags set to true
|
||||
UPDATE "Organization" SET "isAISmartToolsEnabled" = "isAIEnabled", "isAIDataAnalysisEnabled" = "isAIEnabled";
|
||||
|
||||
-- Step 3: Drop old column
|
||||
ALTER TABLE "Organization" DROP COLUMN "isAIEnabled";
|
||||
@@ -0,0 +1,23 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "PasswordResetToken" (
|
||||
"id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"token_hash" TEXT NOT NULL,
|
||||
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "PasswordResetToken_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PasswordResetToken_token_hash_key" ON "PasswordResetToken"("token_hash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PasswordResetToken_userId_key" ON "PasswordResetToken"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PasswordResetToken_expires_at_idx" ON "PasswordResetToken"("expires_at");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PasswordResetToken" ADD CONSTRAINT "PasswordResetToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -661,21 +661,23 @@ model Project {
|
||||
/// @property projects - Collection of projects owned by the organization
|
||||
/// @property billing - JSON field containing billing information
|
||||
/// @property whitelabel - Whitelabel configuration for the organization
|
||||
/// @property isAIEnabled - Controls access to AI-powered features
|
||||
/// @property isAISmartToolsEnabled - Controls access to AI smart tools (e.g. translations) that never touch collected data
|
||||
/// @property isAIDataAnalysisEnabled - Controls access to AI data analysis features that touch experience data
|
||||
model Organization {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @updatedAt @map(name: "updated_at")
|
||||
name String
|
||||
memberships Membership[]
|
||||
projects Project[]
|
||||
billing OrganizationBilling?
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @updatedAt @map(name: "updated_at")
|
||||
name String
|
||||
memberships Membership[]
|
||||
projects Project[]
|
||||
billing OrganizationBilling?
|
||||
/// [OrganizationWhitelabel]
|
||||
whitelabel Json @default("{}")
|
||||
invites Invite[]
|
||||
isAIEnabled Boolean @default(false)
|
||||
teams Team[]
|
||||
apiKeys ApiKey[]
|
||||
whitelabel Json @default("{}")
|
||||
invites Invite[]
|
||||
isAISmartToolsEnabled Boolean @default(false)
|
||||
isAIDataAnalysisEnabled Boolean @default(false)
|
||||
teams Team[]
|
||||
apiKeys ApiKey[]
|
||||
}
|
||||
|
||||
/// Stores billing and Stripe synchronization data for an organization.
|
||||
@@ -879,6 +881,20 @@ model VerificationToken {
|
||||
@@unique([identifier, token])
|
||||
}
|
||||
|
||||
/// Stores the active password reset token for a user.
|
||||
/// Tokens are opaque, hashed at rest, revocable, and single-use.
|
||||
model PasswordResetToken {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @updatedAt @map(name: "updated_at")
|
||||
tokenHash String @unique @map(name: "token_hash")
|
||||
expiresAt DateTime @map(name: "expires_at")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId String @unique
|
||||
|
||||
@@index([expiresAt])
|
||||
}
|
||||
|
||||
/// Represents a user in the Formbricks system.
|
||||
/// Central model for user authentication and profile management.
|
||||
///
|
||||
@@ -897,25 +913,26 @@ model User {
|
||||
emailVerified DateTime? @map(name: "email_verified")
|
||||
|
||||
twoFactorSecret String?
|
||||
twoFactorEnabled Boolean @default(false)
|
||||
twoFactorEnabled Boolean @default(false)
|
||||
backupCodes String?
|
||||
password String?
|
||||
identityProvider IdentityProvider @default(email)
|
||||
identityProvider IdentityProvider @default(email)
|
||||
identityProviderAccountId String?
|
||||
memberships Membership[]
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
passwordResetToken PasswordResetToken?
|
||||
groupId String?
|
||||
invitesCreated Invite[] @relation("inviteCreatedBy")
|
||||
invitesAccepted Invite[] @relation("inviteAcceptedBy")
|
||||
invitesCreated Invite[] @relation("inviteCreatedBy")
|
||||
invitesAccepted Invite[] @relation("inviteAcceptedBy")
|
||||
/// [UserNotificationSettings]
|
||||
notificationSettings Json @default("{}")
|
||||
notificationSettings Json @default("{}")
|
||||
/// [Locale]
|
||||
locale String @default("en-US")
|
||||
locale String @default("en-US")
|
||||
surveys Survey[]
|
||||
teamUsers TeamUser[]
|
||||
lastLoginAt DateTime?
|
||||
isActive Boolean @default(true)
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
|
||||
/// Defines a segment of contacts based on attributes.
|
||||
|
||||
@@ -71,5 +71,6 @@ export const ZOrganization = z.object({
|
||||
updatedAt: z.coerce.date(),
|
||||
name: z.string(),
|
||||
whitelabel: ZOrganizationWhiteLabel,
|
||||
isAIEnabled: z.boolean().default(false) as z.ZodType<Organization["isAIEnabled"]>,
|
||||
isAISmartToolsEnabled: z.boolean().default(false) as z.ZodType<Organization["isAISmartToolsEnabled"]>,
|
||||
isAIDataAnalysisEnabled: z.boolean().default(false) as z.ZodType<Organization["isAIDataAnalysisEnabled"]>,
|
||||
}) satisfies z.ZodType<Organization>;
|
||||
|
||||
@@ -9,21 +9,27 @@ import { TFunction } from "../../src/types/translations";
|
||||
|
||||
interface ForgotPasswordEmailProps extends TEmailTemplateLegalProps {
|
||||
readonly verifyLink: string;
|
||||
readonly linkValidityInMinutes: number;
|
||||
readonly t?: TFunction;
|
||||
}
|
||||
|
||||
export function ForgotPasswordEmail({
|
||||
verifyLink,
|
||||
linkValidityInMinutes,
|
||||
t = mockT,
|
||||
...legalProps
|
||||
}: ForgotPasswordEmailProps): React.JSX.Element {
|
||||
}: Readonly<ForgotPasswordEmailProps>): React.JSX.Element {
|
||||
return (
|
||||
<EmailTemplate t={t} {...legalProps}>
|
||||
<Container>
|
||||
<Heading>{t("emails.forgot_password_email_heading")}</Heading>
|
||||
<Text className="text-sm">{t("emails.forgot_password_email_text")}</Text>
|
||||
<EmailButton href={verifyLink} label={t("emails.forgot_password_email_change_password")} />
|
||||
<Text className="text-sm font-bold">{t("emails.forgot_password_email_link_valid_for_24_hours")}</Text>
|
||||
<Text className="text-sm font-bold">
|
||||
{t("emails.forgot_password_email_link_valid_for_24_hours", {
|
||||
minutes: String(linkValidityInMinutes),
|
||||
})}
|
||||
</Text>
|
||||
<Text className="mb-0 text-sm">{t("emails.forgot_password_email_did_not_request")}</Text>
|
||||
<EmailFooter t={t} />
|
||||
</Container>
|
||||
|
||||
@@ -12,6 +12,7 @@ export const exampleData = {
|
||||
|
||||
forgotPasswordEmail: {
|
||||
verifyLink: "https://app.formbricks.com/auth/forgot-password/reset?token=example-reset-token",
|
||||
linkValidityInMinutes: 30,
|
||||
},
|
||||
|
||||
newEmailVerification: {
|
||||
@@ -132,7 +133,8 @@ export const exampleData = {
|
||||
},
|
||||
},
|
||||
},
|
||||
isAIEnabled: false,
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
} as unknown as TOrganization,
|
||||
},
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ const translations: Record<TranslationKey, TranslationValue> = {
|
||||
"emails.forgot_password_email_change_password": "Change password",
|
||||
"emails.forgot_password_email_did_not_request": "If you didn't request this, please ignore this email.",
|
||||
"emails.forgot_password_email_heading": "Change password",
|
||||
"emails.forgot_password_email_link_valid_for_24_hours": "The link is valid for 24 hours.",
|
||||
"emails.forgot_password_email_link_valid_for_24_hours": "The link is valid for {minutes} minutes.",
|
||||
"emails.forgot_password_email_subject": "Reset your Formbricks password",
|
||||
"emails.forgot_password_email_text":
|
||||
"You have requested a link to change your password. You can do this by clicking the link below:",
|
||||
|
||||
@@ -29,6 +29,7 @@ export async function renderVerificationEmail(
|
||||
export async function renderForgotPasswordEmail(
|
||||
props: {
|
||||
verifyLink: string;
|
||||
linkValidityInMinutes: number;
|
||||
t: TFunction;
|
||||
} & TEmailTemplateLegalProps
|
||||
): Promise<string> {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE = "ERR_INVALID_PASSWORD_RESET_TOKEN";
|
||||
|
||||
class ResourceNotFoundError extends Error {
|
||||
statusCode = 404;
|
||||
resourceId: string | null;
|
||||
@@ -95,6 +97,20 @@ class TooManyRequestsError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
class InvalidPasswordResetTokenError extends Error {
|
||||
statusCode = 400;
|
||||
code: string;
|
||||
reason?: string;
|
||||
userId?: string;
|
||||
constructor(code = INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE, reason?: string, userId?: string) {
|
||||
super(code);
|
||||
this.name = "InvalidPasswordResetTokenError";
|
||||
this.code = code;
|
||||
this.reason = reason;
|
||||
this.userId = userId;
|
||||
}
|
||||
}
|
||||
|
||||
interface NetworkError {
|
||||
code: "network_error";
|
||||
message: string;
|
||||
@@ -127,6 +143,7 @@ export {
|
||||
AuthenticationError,
|
||||
AuthorizationError,
|
||||
TooManyRequestsError,
|
||||
InvalidPasswordResetTokenError,
|
||||
};
|
||||
export type { NetworkError, ForbiddenError };
|
||||
|
||||
@@ -142,6 +159,7 @@ export const EXPECTED_ERROR_NAMES = new Set([
|
||||
"AuthenticationError",
|
||||
"OperationNotAllowedError",
|
||||
"TooManyRequestsError",
|
||||
"InvalidPasswordResetTokenError",
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -85,7 +85,8 @@ export const ZOrganization = z.object({
|
||||
}),
|
||||
whitelabel: ZOrganizationWhitelabel.optional(),
|
||||
billing: ZOrganizationBilling,
|
||||
isAIEnabled: z.boolean().prefault(false),
|
||||
isAISmartToolsEnabled: z.boolean().prefault(false),
|
||||
isAIDataAnalysisEnabled: z.boolean().prefault(false),
|
||||
});
|
||||
|
||||
export const ZOrganizationCreateInput = z.object({
|
||||
@@ -99,7 +100,8 @@ export const ZOrganizationUpdateInput = z.object({
|
||||
name: z.string(),
|
||||
whitelabel: ZOrganizationWhitelabel.optional(),
|
||||
billing: ZOrganizationBilling.optional(),
|
||||
isAIEnabled: z.boolean().optional(),
|
||||
isAISmartToolsEnabled: z.boolean().optional(),
|
||||
isAIDataAnalysisEnabled: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type TOrganizationUpdateInput = z.infer<typeof ZOrganizationUpdateInput>;
|
||||
|
||||
@@ -144,6 +144,7 @@
|
||||
"DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS",
|
||||
"DATABASE_URL",
|
||||
"DEBUG",
|
||||
"DEBUG_SHOW_RESET_LINK",
|
||||
"E2E_TESTING",
|
||||
"EMAIL_AUTH_DISABLED",
|
||||
"EMAIL_VERIFICATION_DISABLED",
|
||||
@@ -199,6 +200,7 @@
|
||||
"OIDC_ISSUER",
|
||||
"OIDC_SIGNING_ALGORITHM",
|
||||
"PASSWORD_RESET_DISABLED",
|
||||
"PASSWORD_RESET_TOKEN_LIFETIME_MINUTES",
|
||||
"PLAYWRIGHT_CI",
|
||||
"PRIVACY_URL",
|
||||
"RATE_LIMITING_DISABLED",
|
||||
|
||||
Reference in New Issue
Block a user