mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-11 12:30:52 -05:00
Compare commits
13 Commits
action-env
...
mertergolu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65e30f3ab0 | ||
|
|
7f1e3502e2 | ||
|
|
be3bbdf2e2 | ||
|
|
b1c2f2c4cd | ||
|
|
7616133a25 | ||
|
|
60139afd81 | ||
|
|
19e5865d05 | ||
|
|
6c5c27f571 | ||
|
|
c12fb1a9f8 | ||
|
|
526439def3 | ||
|
|
4e01ac211f | ||
|
|
f2f3ff6d46 | ||
|
|
b332cf12ca |
16
.devcontainer/Dockerfile
Normal file
16
.devcontainer/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
||||
# [Choice] Node.js version (use -bullseye variants on local arm64/Apple Silicon): 18, 16, 14, 18-bullseye, 16-bullseye, 14-bullseye, 18-buster, 16-buster, 14-buster
|
||||
ARG VARIANT=20
|
||||
FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-${VARIANT}
|
||||
|
||||
# [Optional] Uncomment this section to install additional OS packages.
|
||||
# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||
# && apt-get -y install --no-install-recommends <your-package-list-here>
|
||||
|
||||
# [Optional] Uncomment if you want to install an additional version of node using nvm
|
||||
# ARG EXTRA_NODE_VERSION=10
|
||||
# RUN su node -c "source /usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}"
|
||||
|
||||
# [Optional] Uncomment if you want to install more global node modules
|
||||
# RUN su node -c "npm install -g <your-package-list-here>"
|
||||
|
||||
RUN su node -c "npm install -g pnpm"
|
||||
@@ -1,6 +1,28 @@
|
||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
|
||||
// https://github.com/microsoft/vscode-dev-containers/tree/v0.245.2/containers/javascript-node-postgres
|
||||
// Update the VARIANT arg in docker-compose.yml to pick a Node.js version
|
||||
{
|
||||
"features": {},
|
||||
"image": "mcr.microsoft.com/devcontainers/universal:2",
|
||||
"postAttachCommand": "pnpm go",
|
||||
"postCreateCommand": "cp .env.example .env && sed -i '/^ENCRYPTION_KEY=/c\\ENCRYPTION_KEY='$(openssl rand -hex 32) .env && sed -i '/^NEXTAUTH_SECRET=/c\\NEXTAUTH_SECRET='$(openssl rand -hex 32) .env && sed -i '/^CRON_SECRET=/c\\CRON_SECRET='$(openssl rand -hex 32) .env && pnpm install && pnpm db:migrate:dev"
|
||||
// Configure tool-specific properties.
|
||||
"customizations": {
|
||||
// Configure properties specific to VS Code.
|
||||
"vscode": {
|
||||
// Add the IDs of extensions you want installed when the container is created.
|
||||
"extensions": ["dbaeumer.vscode-eslint"]
|
||||
}
|
||||
},
|
||||
|
||||
"dockerComposeFile": "docker-compose.yml",
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// This can be used to network with other containers or with the host.
|
||||
"forwardPorts": [3000, 5432, 8025],
|
||||
|
||||
"name": "Node.js & PostgreSQL",
|
||||
"postAttachCommand": "pnpm dev --filter=@formbricks/web... --filter=@formbricks/demo...",
|
||||
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
"postCreateCommand": "cp .env.example .env && sed -i '/^ENCRYPTION_KEY=/c\\ENCRYPTION_KEY='$(openssl rand -hex 32) .env && sed -i '/^NEXTAUTH_SECRET=/c\\NEXTAUTH_SECRET='$(openssl rand -hex 32) .env && sed -i '/^CRON_SECRET=/c\\CRON_SECRET='$(openssl rand -hex 32) .env && pnpm install && pnpm db:migrate:dev",
|
||||
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
|
||||
"remoteUser": "node",
|
||||
"service": "app",
|
||||
"workspaceFolder": "/workspace"
|
||||
}
|
||||
|
||||
51
.devcontainer/docker-compose.yml
Normal file
51
.devcontainer/docker-compose.yml
Normal file
@@ -0,0 +1,51 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
# Update 'VARIANT' to pick an LTS version of Node.js: 20, 18, 16, 14.
|
||||
# Append -bullseye or -buster to pin to an OS version.
|
||||
# Use -bullseye variants on local arm64/Apple Silicon.
|
||||
VARIANT: "20"
|
||||
|
||||
volumes:
|
||||
- ..:/workspace:cached
|
||||
|
||||
# Overrides default command so things don't shut down after the process ends.
|
||||
command: sleep infinity
|
||||
|
||||
# Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function.
|
||||
network_mode: service:db
|
||||
# Uncomment the next line to use a non-root user for all processes.
|
||||
# user: node
|
||||
|
||||
# Use "forwardPorts" in **devcontainer.json** to forward an app port locally.
|
||||
# (Adding the "ports" property to this file will not forward from a Codespace.)
|
||||
|
||||
db:
|
||||
image: pgvector/pgvector:pg17
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
environment:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_DB: formbricks
|
||||
# Add "forwardPorts": ["5432"] to **devcontainer.json** to forward PostgreSQL locally.
|
||||
# (Adding the "ports" property to this file will not forward from a Codespace.)
|
||||
|
||||
mailhog:
|
||||
image: mailhog/mailhog
|
||||
network_mode: service:app
|
||||
logging:
|
||||
driver:
|
||||
"none" # disable saving logs
|
||||
# ports:
|
||||
# - 8025:8025 # web ui
|
||||
# 1025:1025 # smtp server
|
||||
|
||||
volumes:
|
||||
postgres-data: null
|
||||
7
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
7
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -10,13 +10,6 @@ body:
|
||||
description: A summary of the issue. This needs to be a clear detailed-rich summary.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: issue-expected-behavior
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: A clear and concise description of what you expected to happen.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: other-information
|
||||
attributes:
|
||||
|
||||
@@ -26,20 +26,15 @@ export const metadata = {
|
||||
|
||||
# n8n Setup
|
||||
|
||||
<Note>
|
||||
The Formbricks n8n node is currently only available in the n8n self-hosted version as a community node. To
|
||||
install it go to "Settings" -> "Community Nodes" and install @formbricks/n8n-nodes-formbricks.
|
||||
</Note>
|
||||
|
||||
n8n allows you to build flexible workflows focused on deep data integration. And with sharable templates and a user-friendly UI, the less technical people on your team can collaborate on them too. Unlike other tools, complexity is not a limitation. So you can build whatever you want — without stressing over budget. Hook up Formbricks with n8n and you can send your data to 350+ other apps. Here is how to do it.
|
||||
|
||||
## Step 1: Setup your survey incl. `questionId` for every question
|
||||
|
||||
<Note>
|
||||
Nailed down your survey? Any changes in the survey cause additional work in the n8n node. It makes sense to
|
||||
first settle on the survey you want to run and then get to setting up n8n.
|
||||
Nail down your survey? Any changes in the survey cause additional work in the n8n node. It makes
|
||||
sense to first settle on the survey you want to run and then get to setting up n8n.
|
||||
</Note>
|
||||
|
||||
## Step 1: Setup your survey incl. `questionId` for every question
|
||||
|
||||
When setting up the node your life will be easier when you change the `questionId`s of your survey questions. You can only do so **before** you publish your survey.
|
||||
|
||||
<MdxImage
|
||||
|
||||
@@ -5,7 +5,6 @@ import "@/styles/tailwind.css";
|
||||
import glob from "fast-glob";
|
||||
import { type Metadata } from "next";
|
||||
import { Jost } from "next/font/google";
|
||||
import Script from "next/script";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
@@ -28,18 +27,6 @@ const RootLayout = async ({ children }: { children: React.ReactNode }) => {
|
||||
|
||||
return (
|
||||
<html lang="en" className="h-full" suppressHydrationWarning>
|
||||
<head>
|
||||
{process.env.NEXT_PUBLIC_LAYER_API_KEY && (
|
||||
<Script
|
||||
strategy="afterInteractive"
|
||||
src="https://storage.googleapis.com/generic-assets/buildwithlayer-widget-4.js"
|
||||
primary-color="#00C4B8"
|
||||
api-key={process.env.NEXT_PUBLIC_LAYER_API_KEY}
|
||||
walkthrough-enabled="false"
|
||||
design-style="copilot"
|
||||
/>
|
||||
)}
|
||||
</head>
|
||||
<body className={`flex min-h-full bg-white antialiased dark:bg-zinc-900 ${jost.className}`}>
|
||||
<Providers>
|
||||
<div className="w-full">
|
||||
|
||||
@@ -19,11 +19,7 @@ interface InviteOrganizationMemberProps {
|
||||
|
||||
const ZInviteOrganizationMemberDetails = z.object({
|
||||
email: z.string().email(),
|
||||
inviteMessage: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.refine((value) => !/https?:\/\/|<script/i.test(value), "Invite message cannot contain URLs or scripts"),
|
||||
inviteMessage: z.string().trim().min(1),
|
||||
});
|
||||
type TInviteOrganizationMemberDetails = z.infer<typeof ZInviteOrganizationMemberDetails>;
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ const Page = async ({ params, searchParams }) => {
|
||||
getProductByEnvironmentId(params.environmentId),
|
||||
getEnvironment(params.environmentId),
|
||||
getActionClasses(params.environmentId),
|
||||
getAttributeClasses(params.environmentId, undefined, { skipArchived: true }),
|
||||
getAttributeClasses(params.environmentId),
|
||||
getResponseCountBySurveyId(params.surveyId),
|
||||
getOrganizationByEnvironmentId(params.environmentId),
|
||||
getServerSession(authOptions),
|
||||
|
||||
@@ -20,17 +20,13 @@ export const getSegmentsByAttributeClassAction = authenticatedActionClient
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "productTeam",
|
||||
minPermission: "read",
|
||||
productId: await getProductIdFromEnvironmentId(parsedInput.environmentId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const segments = await getSegmentsByAttributeClassName(
|
||||
parsedInput.environmentId,
|
||||
parsedInput.attributeClass.name
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useTranslations } from "next-intl";
|
||||
import { useMemo, useState } from "react";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { Label } from "@formbricks/ui/components/Label";
|
||||
import { Switch } from "@formbricks/ui/components/Switch";
|
||||
import { AttributeDetailModal } from "./AttributeDetailModal";
|
||||
import { AttributeClassDataRow } from "./AttributeRowData";
|
||||
@@ -50,15 +49,8 @@ export const AttributeClassesTable = ({
|
||||
{hasArchived && (
|
||||
<div className="my-4 flex items-center justify-end text-right">
|
||||
<div className="flex items-center text-sm font-medium">
|
||||
<Label htmlFor="showArchivedToggle" className="cursor-pointer">
|
||||
{t("environments.attributes.show_archived")}
|
||||
</Label>
|
||||
<Switch
|
||||
id="showArchivedToggle"
|
||||
className="mx-3"
|
||||
checked={showArchived}
|
||||
onCheckedChange={toggleShowArchived}
|
||||
/>
|
||||
{t("environments.attributes.show_archived")}
|
||||
<Switch className="mx-3" checked={showArchived} onCheckedChange={toggleShowArchived} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -24,7 +24,7 @@ const Page = async ({ params }) => {
|
||||
const [environment, segments, attributeClasses, organization, product] = await Promise.all([
|
||||
getEnvironment(params.environmentId),
|
||||
getSegments(params.environmentId),
|
||||
getAttributeClasses(params.environmentId, undefined, { skipArchived: true }),
|
||||
getAttributeClasses(params.environmentId),
|
||||
getOrganizationByEnvironmentId(params.environmentId),
|
||||
getProductByEnvironmentId(params.environmentId),
|
||||
]);
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { createActionClassAction } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/actions";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Code2Icon, MousePointerClickIcon, SparklesIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useEffect, useState } from "react";
|
||||
import { convertDateTimeStringShort } from "@formbricks/lib/time";
|
||||
import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings";
|
||||
import { TActionClass, TActionClassInput, TActionClassInputCode } from "@formbricks/types/action-classes";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { ErrorComponent } from "@formbricks/ui/components/ErrorComponent";
|
||||
import { Label } from "@formbricks/ui/components/Label";
|
||||
import { LoadingSpinner } from "@formbricks/ui/components/LoadingSpinner";
|
||||
@@ -19,25 +15,15 @@ import { getActiveInactiveSurveysAction } from "../actions";
|
||||
interface ActivityTabProps {
|
||||
actionClass: TActionClass;
|
||||
environmentId: string;
|
||||
environment: TEnvironment;
|
||||
otherEnvActionClasses: TActionClass[];
|
||||
otherEnvironment: TEnvironment;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export const ActionActivityTab = ({
|
||||
actionClass,
|
||||
otherEnvActionClasses,
|
||||
otherEnvironment,
|
||||
environmentId,
|
||||
environment,
|
||||
isReadOnly,
|
||||
}: ActivityTabProps) => {
|
||||
export const ActionActivityTab = ({ actionClass, environmentId }: ActivityTabProps) => {
|
||||
const t = useTranslations();
|
||||
const [activeSurveys, setActiveSurveys] = useState<string[] | undefined>();
|
||||
const [inactiveSurveys, setInactiveSurveys] = useState<string[] | undefined>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
|
||||
@@ -46,6 +32,7 @@ export const ActionActivityTab = ({
|
||||
const getActiveInactiveSurveysResponse = await getActiveInactiveSurveysAction({
|
||||
actionClassId: actionClass.id,
|
||||
});
|
||||
console.log(getActiveInactiveSurveysResponse, "randike");
|
||||
if (getActiveInactiveSurveysResponse?.data) {
|
||||
setActiveSurveys(getActiveInactiveSurveysResponse.data.activeSurveys);
|
||||
setInactiveSurveys(getActiveInactiveSurveysResponse.data.inactiveSurveys);
|
||||
@@ -59,57 +46,6 @@ export const ActionActivityTab = ({
|
||||
updateState();
|
||||
}, [actionClass.id, environmentId]);
|
||||
|
||||
const actionClassNames = useMemo(
|
||||
() => otherEnvActionClasses.map((actionClass) => actionClass.name),
|
||||
[otherEnvActionClasses]
|
||||
);
|
||||
|
||||
const actionClassKeys = useMemo(() => {
|
||||
const codeActionClasses: TActionClassInputCode[] = otherEnvActionClasses.filter(
|
||||
(actionClass) => actionClass.type === "code"
|
||||
) as TActionClassInputCode[];
|
||||
|
||||
return codeActionClasses.map((actionClass) => actionClass.key);
|
||||
}, [otherEnvActionClasses]);
|
||||
|
||||
const copyAction = async (data: TActionClassInput) => {
|
||||
const { type } = data;
|
||||
let copyName = data.name;
|
||||
try {
|
||||
if (isReadOnly) {
|
||||
throw new Error(t("common.you_are_not_authorised_to_perform_this_action"));
|
||||
}
|
||||
|
||||
if (copyName && actionClassNames.includes(copyName)) {
|
||||
while (actionClassNames.includes(copyName)) {
|
||||
copyName += " (copy)";
|
||||
}
|
||||
}
|
||||
|
||||
if (type === "code" && data.key && actionClassKeys.includes(data.key)) {
|
||||
throw new Error(t("environments.actions.action_with_key_already_exists", { key: data.key }));
|
||||
}
|
||||
|
||||
let updatedAction = {
|
||||
...data,
|
||||
name: copyName.trim(),
|
||||
environmentId: otherEnvironment.id,
|
||||
};
|
||||
|
||||
const createActionClassResponse = await createActionClassAction({
|
||||
action: updatedAction as TActionClassInput,
|
||||
});
|
||||
|
||||
if (!createActionClassResponse?.data) {
|
||||
throw new Error(t("environments.actions.action_copy_failed", {}));
|
||||
}
|
||||
|
||||
toast.success(t("environments.actions.action_copied_successfully"));
|
||||
} catch (e: any) {
|
||||
toast.error(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <LoadingSpinner />;
|
||||
if (error) return <ErrorComponent />;
|
||||
|
||||
@@ -163,22 +99,6 @@ export const ActionActivityTab = ({
|
||||
<p className="text-sm text-slate-700">{capitalizeFirstLetter(actionClass.type)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="">
|
||||
<Label className="text-xs font-normal text-slate-500">Environment</Label>
|
||||
<div className="items-center-center flex gap-2">
|
||||
<p className="text-xs text-slate-700">
|
||||
{environment.type === "development" ? "Development" : "Production"}
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => {
|
||||
copyAction(actionClass);
|
||||
}}
|
||||
className="m-0 p-0 text-xs font-medium text-black underline underline-offset-4 focus:ring-0 focus:ring-offset-0"
|
||||
variant="minimal">
|
||||
{environment.type === "development" ? "Copy to Production" : "Copy to Development"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,27 +2,20 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { ActionDetailModal } from "./ActionDetailModal";
|
||||
|
||||
interface ActionClassesTableProps {
|
||||
environmentId: string;
|
||||
actionClasses: TActionClass[];
|
||||
environment: TEnvironment;
|
||||
children: [JSX.Element, JSX.Element[]];
|
||||
isReadOnly: boolean;
|
||||
otherEnvironment: TEnvironment;
|
||||
otherEnvActionClasses: TActionClass[];
|
||||
}
|
||||
|
||||
export const ActionClassesTable = ({
|
||||
environmentId,
|
||||
actionClasses,
|
||||
environment,
|
||||
children: [TableHeading, actionRows],
|
||||
isReadOnly,
|
||||
otherEnvActionClasses,
|
||||
otherEnvironment,
|
||||
}: ActionClassesTableProps) => {
|
||||
const [isActionDetailModalOpen, setActionDetailModalOpen] = useState(false);
|
||||
|
||||
@@ -55,14 +48,11 @@ export const ActionClassesTable = ({
|
||||
{activeActionClass && (
|
||||
<ActionDetailModal
|
||||
environmentId={environmentId}
|
||||
environment={environment}
|
||||
open={isActionDetailModalOpen}
|
||||
setOpen={setActionDetailModalOpen}
|
||||
actionClasses={actionClasses}
|
||||
actionClass={activeActionClass}
|
||||
isReadOnly={isReadOnly}
|
||||
otherEnvActionClasses={otherEnvActionClasses}
|
||||
otherEnvironment={otherEnvironment}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
import { Code2Icon, MousePointerClickIcon, SparklesIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { ModalWithTabs } from "@formbricks/ui/components/ModalWithTabs";
|
||||
import { ActionActivityTab } from "./ActionActivityTab";
|
||||
import { ActionSettingsTab } from "./ActionSettingsTab";
|
||||
|
||||
interface ActionDetailModalProps {
|
||||
environmentId: string;
|
||||
environment: TEnvironment;
|
||||
open: boolean;
|
||||
setOpen: (v: boolean) => void;
|
||||
actionClass: TActionClass;
|
||||
actionClasses: TActionClass[];
|
||||
isReadOnly: boolean;
|
||||
otherEnvironment: TEnvironment;
|
||||
otherEnvActionClasses: TActionClass[];
|
||||
}
|
||||
|
||||
export const ActionDetailModal = ({
|
||||
@@ -24,25 +20,13 @@ export const ActionDetailModal = ({
|
||||
setOpen,
|
||||
actionClass,
|
||||
actionClasses,
|
||||
environment,
|
||||
isReadOnly,
|
||||
otherEnvActionClasses,
|
||||
otherEnvironment,
|
||||
}: ActionDetailModalProps) => {
|
||||
const t = useTranslations();
|
||||
const tabs = [
|
||||
{
|
||||
title: t("common.activity"),
|
||||
children: (
|
||||
<ActionActivityTab
|
||||
otherEnvActionClasses={otherEnvActionClasses}
|
||||
otherEnvironment={otherEnvironment}
|
||||
isReadOnly={isReadOnly}
|
||||
environment={environment}
|
||||
actionClass={actionClass}
|
||||
environmentId={environmentId}
|
||||
/>
|
||||
),
|
||||
children: <ActionActivityTab actionClass={actionClass} environmentId={environmentId} />,
|
||||
},
|
||||
{
|
||||
title: t("common.settings"),
|
||||
|
||||
@@ -10,7 +10,6 @@ import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getActionClasses } from "@formbricks/lib/actionClass/service";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { getEnvironments } from "@formbricks/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
|
||||
@@ -45,17 +44,6 @@ const Page = async ({ params }) => {
|
||||
throw new Error(t("common.product_not_found"));
|
||||
}
|
||||
|
||||
const environments = await getEnvironments(product.id);
|
||||
const currentEnvironment = environments.find((env) => env.id === params.environmentId);
|
||||
|
||||
if (!currentEnvironment) {
|
||||
throw new Error(t("common.environment_not_found"));
|
||||
}
|
||||
|
||||
const otherEnvironment = environments.filter((env) => env.id !== params.environmentId)[0];
|
||||
|
||||
const otherEnvActionClasses = await getActionClasses(otherEnvironment.id);
|
||||
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
|
||||
const { isMember, isBilling } = getAccessFlags(currentUserMembership?.role);
|
||||
|
||||
@@ -81,9 +69,6 @@ const Page = async ({ params }) => {
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.actions")} cta={!isReadOnly ? renderAddActionButton() : undefined} />
|
||||
<ActionClassesTable
|
||||
environment={currentEnvironment}
|
||||
otherEnvironment={otherEnvironment}
|
||||
otherEnvActionClasses={otherEnvActionClasses}
|
||||
environmentId={params.environmentId}
|
||||
actionClasses={actionClasses}
|
||||
isReadOnly={isReadOnly}>
|
||||
|
||||
@@ -59,13 +59,15 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
|
||||
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
|
||||
const membershipRole = currentUserMembership?.role;
|
||||
const { isMember } = getAccessFlags(membershipRole);
|
||||
const { isOwner, isManager } = getAccessFlags(membershipRole);
|
||||
|
||||
const isOwnerOrManager = isOwner || isManager;
|
||||
|
||||
const { features, lastChecked, isPendingDowngrade, active } = await getEnterpriseLicense();
|
||||
|
||||
const productPermission = await getProductPermissionByUserId(session.user.id, environment.productId);
|
||||
|
||||
if (isMember && !productPermission) {
|
||||
if (!isOwnerOrManager && !productPermission) {
|
||||
throw new Error(t("common.product_permission_not_found"));
|
||||
}
|
||||
|
||||
|
||||
@@ -222,7 +222,7 @@ export const MainNavigation = ({
|
||||
{
|
||||
label: t("common.billing"),
|
||||
href: `/environments/${environment.id}/settings/billing`,
|
||||
hidden: !isFormbricksCloud,
|
||||
hidden: !isFormbricksCloud || isPricingDisabled,
|
||||
icon: CreditCardIcon,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -40,7 +40,7 @@ export const OrganizationSettingsNavbar = ({
|
||||
id: "billing",
|
||||
label: t("common.billing"),
|
||||
href: `/environments/${environmentId}/settings/billing`,
|
||||
hidden: !isFormbricksCloud || loading,
|
||||
hidden: !isFormbricksCloud || isPricingDisabled || loading,
|
||||
current: pathname?.includes("/billing"),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { AddMemberRole } from "@/modules/ee/role-management/components/add-member-role";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { OrganizationRole } from "@prisma/client";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { TOrganizationRole, ZOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { ZUserName } from "@formbricks/types/user";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { Alert, AlertDescription } from "@formbricks/ui/components/Alert";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import { Input } from "@formbricks/ui/components/Input";
|
||||
@@ -21,7 +18,6 @@ interface IndividualInviteTabProps {
|
||||
isFormbricksCloud: boolean;
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export const IndividualInviteTab = ({
|
||||
setOpen,
|
||||
onSubmit,
|
||||
@@ -29,13 +25,6 @@ export const IndividualInviteTab = ({
|
||||
isFormbricksCloud,
|
||||
environmentId,
|
||||
}: IndividualInviteTabProps) => {
|
||||
const ZFormSchema = z.object({
|
||||
name: ZUserName,
|
||||
email: z.string().email("Invalid email address"),
|
||||
role: ZOrganizationRole,
|
||||
});
|
||||
|
||||
type TFormData = z.infer<typeof ZFormSchema>;
|
||||
const t = useTranslations();
|
||||
const {
|
||||
register,
|
||||
@@ -44,13 +33,12 @@ export const IndividualInviteTab = ({
|
||||
reset,
|
||||
control,
|
||||
watch,
|
||||
formState: { isSubmitting, errors },
|
||||
} = useForm<TFormData>({
|
||||
resolver: zodResolver(ZFormSchema),
|
||||
defaultValues: {
|
||||
role: "owner",
|
||||
},
|
||||
});
|
||||
formState: { isSubmitting },
|
||||
} = useForm<{
|
||||
name: string;
|
||||
email: string;
|
||||
role: TOrganizationRole;
|
||||
}>();
|
||||
|
||||
const submitEventClass = async () => {
|
||||
const data = getValues();
|
||||
@@ -67,10 +55,9 @@ export const IndividualInviteTab = ({
|
||||
<Label htmlFor="memberNameInput">{t("common.full_name")}</Label>
|
||||
<Input
|
||||
id="memberNameInput"
|
||||
placeholder="Hans Wurst"
|
||||
placeholder="e.g. Hans Wurst"
|
||||
{...register("name", { required: true, validate: (value) => value.trim() !== "" })}
|
||||
/>
|
||||
{errors.name && <p className="mt-1 text-sm text-red-500">{errors.name.message}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="memberEmailInput">{t("common.email")}</Label>
|
||||
|
||||
@@ -43,7 +43,7 @@ export const EmbedView = ({
|
||||
return (
|
||||
<div className="h-full overflow-hidden">
|
||||
{!disableBack && (
|
||||
<div className="border-b border-slate-200 py-2 pl-2">
|
||||
<div className="border-b border-slate-200 py-2">
|
||||
<Button
|
||||
variant="minimal"
|
||||
className="focus:ring-0"
|
||||
@@ -63,7 +63,6 @@ export const EmbedView = ({
|
||||
variant="minimal"
|
||||
key={tab.id}
|
||||
onClick={() => setActiveId(tab.id)}
|
||||
autoFocus={tab.id === activeId}
|
||||
className={cn(
|
||||
"rounded-md border px-4 py-2 text-slate-600",
|
||||
// "focus:ring-0 focus:ring-offset-0", // enable these classes to remove the focus rings on buttons
|
||||
|
||||
@@ -6,22 +6,8 @@ import {
|
||||
} from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||
import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
|
||||
import { getFormattedFilters, getTodayDate } from "@/app/lib/surveys/surveys";
|
||||
import {
|
||||
differenceInDays,
|
||||
endOfMonth,
|
||||
endOfQuarter,
|
||||
endOfYear,
|
||||
format,
|
||||
startOfDay,
|
||||
startOfMonth,
|
||||
startOfQuarter,
|
||||
startOfYear,
|
||||
subDays,
|
||||
subMonths,
|
||||
subQuarters,
|
||||
subYears,
|
||||
} from "date-fns";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { differenceInDays, format, startOfDay, subDays } from "date-fns";
|
||||
import { ArrowDownToLineIcon, ChevronDown, ChevronUp, DownloadIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useParams } from "next/navigation";
|
||||
@@ -52,13 +38,6 @@ enum FilterDropDownLabels {
|
||||
ALL_TIME = "environments.surveys.summary.all_time",
|
||||
LAST_7_DAYS = "environments.surveys.summary.last_7_days",
|
||||
LAST_30_DAYS = "environments.surveys.summary.last_30_days",
|
||||
THIS_MONTH = "environments.surveys.summary.this_month",
|
||||
LAST_MONTH = "environments.surveys.summary.last_month",
|
||||
LAST_6_MONTHS = "environments.surveys.summary.last_6_months",
|
||||
THIS_QUARTER = "environments.surveys.summary.this_quarter",
|
||||
LAST_QUARTER = "environments.surveys.summary.last_quarter",
|
||||
THIS_YEAR = "environments.surveys.summary.this_year",
|
||||
LAST_YEAR = "environments.surveys.summary.last_year",
|
||||
CUSTOM_RANGE = "environments.surveys.summary.custom_range",
|
||||
}
|
||||
|
||||
@@ -66,62 +45,15 @@ interface CustomFilterProps {
|
||||
survey: TSurvey;
|
||||
}
|
||||
|
||||
const getDateRangeLabel = (from: Date, to: Date): FilterDropDownLabels => {
|
||||
const dateRanges = [
|
||||
{
|
||||
label: FilterDropDownLabels.LAST_7_DAYS,
|
||||
matches: () => differenceInDays(to, from) === 7,
|
||||
},
|
||||
{
|
||||
label: FilterDropDownLabels.LAST_30_DAYS,
|
||||
matches: () => differenceInDays(to, from) === 30,
|
||||
},
|
||||
{
|
||||
label: FilterDropDownLabels.THIS_MONTH,
|
||||
matches: () =>
|
||||
format(from, "yyyy-MM-dd") === format(startOfMonth(new Date()), "yyyy-MM-dd") &&
|
||||
format(to, "yyyy-MM-dd") === format(getTodayDate(), "yyyy-MM-dd"),
|
||||
},
|
||||
{
|
||||
label: FilterDropDownLabels.LAST_MONTH,
|
||||
matches: () =>
|
||||
format(from, "yyyy-MM-dd") === format(startOfMonth(subMonths(new Date(), 1)), "yyyy-MM-dd") &&
|
||||
format(to, "yyyy-MM-dd") === format(endOfMonth(subMonths(getTodayDate(), 1)), "yyyy-MM-dd"),
|
||||
},
|
||||
{
|
||||
label: FilterDropDownLabels.LAST_6_MONTHS,
|
||||
matches: () =>
|
||||
format(from, "yyyy-MM-dd") === format(startOfMonth(subMonths(new Date(), 6)), "yyyy-MM-dd") &&
|
||||
format(to, "yyyy-MM-dd") === format(endOfMonth(getTodayDate()), "yyyy-MM-dd"),
|
||||
},
|
||||
{
|
||||
label: FilterDropDownLabels.THIS_QUARTER,
|
||||
matches: () =>
|
||||
format(from, "yyyy-MM-dd") === format(startOfQuarter(new Date()), "yyyy-MM-dd") &&
|
||||
format(to, "yyyy-MM-dd") === format(endOfQuarter(getTodayDate()), "yyyy-MM-dd"),
|
||||
},
|
||||
{
|
||||
label: FilterDropDownLabels.LAST_QUARTER,
|
||||
matches: () =>
|
||||
format(from, "yyyy-MM-dd") === format(startOfQuarter(subQuarters(new Date(), 1)), "yyyy-MM-dd") &&
|
||||
format(to, "yyyy-MM-dd") === format(endOfQuarter(subQuarters(getTodayDate(), 1)), "yyyy-MM-dd"),
|
||||
},
|
||||
{
|
||||
label: FilterDropDownLabels.THIS_YEAR,
|
||||
matches: () =>
|
||||
format(from, "yyyy-MM-dd") === format(startOfYear(new Date()), "yyyy-MM-dd") &&
|
||||
format(to, "yyyy-MM-dd") === format(endOfYear(getTodayDate()), "yyyy-MM-dd"),
|
||||
},
|
||||
{
|
||||
label: FilterDropDownLabels.LAST_YEAR,
|
||||
matches: () =>
|
||||
format(from, "yyyy-MM-dd") === format(startOfYear(subYears(new Date(), 1)), "yyyy-MM-dd") &&
|
||||
format(to, "yyyy-MM-dd") === format(endOfYear(subYears(getTodayDate(), 1)), "yyyy-MM-dd"),
|
||||
},
|
||||
];
|
||||
|
||||
const matchedRange = dateRanges.find((range) => range.matches());
|
||||
return matchedRange ? matchedRange.label : FilterDropDownLabels.CUSTOM_RANGE;
|
||||
const getDifferenceOfDays = (from, to) => {
|
||||
const days = differenceInDays(to, from);
|
||||
if (days === 7) {
|
||||
return FilterDropDownLabels.LAST_7_DAYS;
|
||||
} else if (days === 30) {
|
||||
return FilterDropDownLabels.LAST_30_DAYS;
|
||||
} else {
|
||||
return FilterDropDownLabels.CUSTOM_RANGE;
|
||||
}
|
||||
};
|
||||
|
||||
export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
@@ -131,7 +63,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
const { selectedFilter, dateRange, setDateRange, resetState } = useResponseFilter();
|
||||
const [filterRange, setFilterRange] = useState<FilterDropDownLabels>(
|
||||
dateRange.from && dateRange.to
|
||||
? getDateRangeLabel(dateRange.from, dateRange.to)
|
||||
? getDifferenceOfDays(dateRange.from, dateRange.to)
|
||||
: FilterDropDownLabels.ALL_TIME
|
||||
);
|
||||
const [selectingDate, setSelectingDate] = useState<DateSelected>(DateSelected.FROM);
|
||||
@@ -312,67 +244,6 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
}}>
|
||||
<p className="text-slate-700">{t(FilterDropDownLabels.LAST_30_DAYS)}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(FilterDropDownLabels.THIS_MONTH);
|
||||
setDateRange({ from: startOfMonth(new Date()), to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{t(FilterDropDownLabels.THIS_MONTH)}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(FilterDropDownLabels.LAST_MONTH);
|
||||
setDateRange({
|
||||
from: startOfMonth(subMonths(new Date(), 1)),
|
||||
to: endOfMonth(subMonths(getTodayDate(), 1)),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{t(FilterDropDownLabels.LAST_MONTH)}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(FilterDropDownLabels.THIS_QUARTER);
|
||||
setDateRange({ from: startOfQuarter(new Date()), to: endOfQuarter(getTodayDate()) });
|
||||
}}>
|
||||
<p className="text-slate-700">{t(FilterDropDownLabels.THIS_QUARTER)}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(FilterDropDownLabels.LAST_QUARTER);
|
||||
setDateRange({
|
||||
from: startOfQuarter(subQuarters(new Date(), 1)),
|
||||
to: endOfQuarter(subQuarters(getTodayDate(), 1)),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{t(FilterDropDownLabels.LAST_QUARTER)}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(FilterDropDownLabels.LAST_6_MONTHS);
|
||||
setDateRange({
|
||||
from: startOfMonth(subMonths(new Date(), 6)),
|
||||
to: endOfMonth(getTodayDate()),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{t(FilterDropDownLabels.LAST_6_MONTHS)}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(FilterDropDownLabels.THIS_YEAR);
|
||||
setDateRange({ from: startOfYear(new Date()), to: endOfYear(getTodayDate()) });
|
||||
}}>
|
||||
<p className="text-slate-700">{t(FilterDropDownLabels.THIS_YEAR)}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(FilterDropDownLabels.LAST_YEAR);
|
||||
setDateRange({
|
||||
from: startOfYear(subYears(new Date(), 1)),
|
||||
to: endOfYear(subYears(getTodayDate(), 1)),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{t(FilterDropDownLabels.LAST_YEAR)}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setIsDatePickerOpen(true);
|
||||
|
||||
@@ -2,19 +2,16 @@
|
||||
|
||||
import { TwoFactor } from "@/app/(auth)/auth/login/components/TwoFactor";
|
||||
import { TwoFactorBackup } from "@/app/(auth)/auth/login/components/TwoFactorBackup";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { XCircleIcon } from "lucide-react";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/dist/client/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { Controller, FormProvider, SubmitHandler, useForm } from "react-hook-form";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@formbricks/lib/localStorage";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import { FormControl, FormError, FormField, FormItem } from "@formbricks/ui/components/Form";
|
||||
import { PasswordInput } from "@formbricks/ui/components/PasswordInput";
|
||||
import { AzureButton } from "@formbricks/ui/components/SignupOptions/components/AzureButton";
|
||||
import { GithubButton } from "@formbricks/ui/components/SignupOptions/components/GithubButton";
|
||||
@@ -56,23 +53,6 @@ export const SigninForm = ({
|
||||
const emailRef = useRef<HTMLInputElement>(null);
|
||||
const formMethods = useForm<TSigninFormState>();
|
||||
const callbackUrl = searchParams?.get("callbackUrl");
|
||||
const ZSignInInput = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
totpCode: z.string().optional(),
|
||||
backupCode: z.string().optional(),
|
||||
});
|
||||
|
||||
type TSignInInput = z.infer<typeof ZSignInInput>;
|
||||
const form = useForm<TSignInInput>({
|
||||
defaultValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
totpCode: "",
|
||||
backupCode: "",
|
||||
},
|
||||
resolver: zodResolver(ZSignInInput),
|
||||
});
|
||||
const t = useTranslations();
|
||||
const onSubmit: SubmitHandler<TSigninFormState> = async (data) => {
|
||||
setLoggingIn(true);
|
||||
@@ -157,11 +137,11 @@ export const SigninForm = ({
|
||||
|
||||
const TwoFactorComponent = useMemo(() => {
|
||||
if (totpBackup) {
|
||||
return <TwoFactorBackup form={form} />;
|
||||
return <TwoFactorBackup />;
|
||||
}
|
||||
|
||||
if (totpLogin) {
|
||||
return <TwoFactor form={form} />;
|
||||
return <TwoFactor />;
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -173,58 +153,53 @@ export const SigninForm = ({
|
||||
<h1 className="mb-4 text-slate-700">{formLabel}</h1>
|
||||
|
||||
<div className="space-y-2">
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2">
|
||||
<form onSubmit={formMethods.handleSubmit(onSubmit)} className="space-y-2">
|
||||
{TwoFactorComponent}
|
||||
|
||||
{showLogin && (
|
||||
<div className={cn(totpLogin && "hidden", "space-y-2")}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<div>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={field.value}
|
||||
onChange={(email) => field.onChange(email)}
|
||||
placeholder="work@email.com"
|
||||
defaultValue={searchParams?.get("email") || ""}
|
||||
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
||||
/>
|
||||
{error?.message && <FormError className="text-left">{error.message}</FormError>}
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<div>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="*******"
|
||||
aria-placeholder="password"
|
||||
onFocus={() => setIsPasswordFocused(true)}
|
||||
required
|
||||
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
||||
value={field.value}
|
||||
onChange={(password) => field.onChange(password)}
|
||||
/>
|
||||
{error?.message && <FormError className="text-left">{error.message}</FormError>}
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className={cn(totpLogin && "hidden")}>
|
||||
<div className="mb-2 transition-all duration-500 ease-in-out">
|
||||
<label htmlFor="email" className="sr-only">
|
||||
{t("common.email")}
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
placeholder="work@email.com"
|
||||
defaultValue={searchParams?.get("email") || ""}
|
||||
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
||||
{...formMethods.register("email", {
|
||||
required: true,
|
||||
pattern: /\S+@\S+\.\S+/,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className="transition-all duration-500 ease-in-out">
|
||||
<label htmlFor="password" className="sr-only">
|
||||
{t("common.password")}
|
||||
</label>
|
||||
<Controller
|
||||
name="password"
|
||||
control={formMethods.control}
|
||||
render={({ field }) => (
|
||||
<PasswordInput
|
||||
id="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="*******"
|
||||
aria-placeholder="password"
|
||||
onFocus={() => setIsPasswordFocused(true)}
|
||||
required
|
||||
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
rules={{
|
||||
required: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{passwordResetEnabled && isPasswordFocused && (
|
||||
<div className="ml-1 text-right transition-all duration-500 ease-in-out">
|
||||
<Link
|
||||
|
||||
@@ -2,24 +2,11 @@
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import React from "react";
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
import { FormControl, FormField, FormItem } from "@formbricks/ui/components/Form";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import { OTPInput } from "@formbricks/ui/components/OTPInput";
|
||||
|
||||
interface TwoFactorProps {
|
||||
form: UseFormReturn<
|
||||
{
|
||||
email: string;
|
||||
password: string;
|
||||
totpCode?: string | undefined;
|
||||
backupCode?: string | undefined;
|
||||
},
|
||||
any,
|
||||
undefined
|
||||
>;
|
||||
}
|
||||
|
||||
export const TwoFactor = ({ form }: TwoFactorProps) => {
|
||||
export const TwoFactor = () => {
|
||||
const { control } = useFormContext();
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
@@ -29,15 +16,11 @@ export const TwoFactor = ({ form }: TwoFactorProps) => {
|
||||
{t("auth.login.enter_your_two_factor_authentication_code")}
|
||||
</label>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
<Controller
|
||||
control={control}
|
||||
name="totpCode"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<OTPInput value={field.value ?? ""} onChange={field.onChange} valueLength={6} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
<OTPInput value={field.value ?? ""} onChange={field.onChange} valueLength={6} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,25 +2,11 @@
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import React from "react";
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
import { FormField, FormItem } from "@formbricks/ui/components/Form";
|
||||
import { FormControl } from "@formbricks/ui/components/Form";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import { Input } from "@formbricks/ui/components/Input";
|
||||
|
||||
interface TwoFactorBackupProps {
|
||||
form: UseFormReturn<
|
||||
{
|
||||
email: string;
|
||||
password: string;
|
||||
totpCode?: string | undefined;
|
||||
backupCode?: string | undefined;
|
||||
},
|
||||
any,
|
||||
undefined
|
||||
>;
|
||||
}
|
||||
|
||||
export const TwoFactorBackup = ({ form }: TwoFactorBackupProps) => {
|
||||
export const TwoFactorBackup = () => {
|
||||
const { register } = useFormContext();
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
@@ -29,23 +15,12 @@ export const TwoFactorBackup = ({ form }: TwoFactorBackupProps) => {
|
||||
<label htmlFor="totpBackup" className="sr-only">
|
||||
{t("auth.login.backup_code")}
|
||||
</label>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="backupCode"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<Input
|
||||
id="totpBackup"
|
||||
required
|
||||
placeholder="XXXXX-XXXXX"
|
||||
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
||||
value={field.value}
|
||||
onChange={(e) => field.onChange(e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
<Input
|
||||
id="totpBackup"
|
||||
required
|
||||
placeholder="XXXXX-XXXXX"
|
||||
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
||||
{...register("backupCode")}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -35,7 +35,6 @@ interface PricingTableProps {
|
||||
SCALE: string;
|
||||
ENTERPRISE: string;
|
||||
};
|
||||
hasBillingRights: boolean;
|
||||
}
|
||||
|
||||
export const PricingTable = ({
|
||||
@@ -45,7 +44,6 @@ export const PricingTable = ({
|
||||
productFeatureKeys,
|
||||
responseCount,
|
||||
stripePriceLookupKeys,
|
||||
hasBillingRights,
|
||||
}: PricingTableProps) => {
|
||||
const t = useTranslations();
|
||||
const [planPeriod, setPlanPeriod] = useState<TOrganizationBillingPeriod>(
|
||||
@@ -222,50 +220,48 @@ export const PricingTable = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasBillingRights && (
|
||||
<div className="mx-auto mb-12">
|
||||
<div className="gap-x-2">
|
||||
<div className="mb-4 flex w-fit cursor-pointer overflow-hidden rounded-lg border border-slate-200 p-1 lg:mb-0">
|
||||
<div
|
||||
className={`flex-1 rounded-md px-4 py-0.5 text-center ${
|
||||
planPeriod === "monthly" ? "bg-slate-200 font-semibold" : "bg-transparent"
|
||||
}`}
|
||||
onClick={() => handleMonthlyToggle("monthly")}>
|
||||
{t("environments.settings.billing.monthly")}
|
||||
</div>
|
||||
<div
|
||||
className={`flex-1 items-center whitespace-nowrap rounded-md py-0.5 pl-4 pr-2 text-center ${
|
||||
planPeriod === "yearly" ? "bg-slate-200 font-semibold" : "bg-transparent"
|
||||
}`}
|
||||
onClick={() => handleMonthlyToggle("yearly")}>
|
||||
{t("environments.settings.billing.annually")}
|
||||
<span className="ml-2 inline-flex items-center rounded-full border border-green-200 bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800">
|
||||
{t("environments.settings.billing.get_2_months_free")} 🔥
|
||||
</span>
|
||||
</div>
|
||||
<div className="mx-auto mb-12">
|
||||
<div className="flex gap-x-2">
|
||||
<div className="mb-4 flex w-fit cursor-pointer overflow-hidden rounded-lg border border-slate-200 p-1 lg:mb-0">
|
||||
<div
|
||||
className={`flex-1 rounded-md px-4 py-0.5 text-center ${
|
||||
planPeriod === "monthly" ? "bg-slate-200 font-semibold" : "bg-transparent"
|
||||
}`}
|
||||
onClick={() => handleMonthlyToggle("monthly")}>
|
||||
{t("environments.settings.billing.monthly")}
|
||||
</div>
|
||||
<div className="relative mx-auto grid max-w-md grid-cols-1 gap-y-8 lg:mx-0 lg:-mb-14 lg:max-w-none lg:grid-cols-4">
|
||||
<div
|
||||
className="hidden lg:absolute lg:inset-x-px lg:bottom-0 lg:top-4 lg:block lg:rounded-xl lg:rounded-t-2xl lg:border lg:border-slate-200 lg:bg-slate-100 lg:pb-8 lg:ring-1 lg:ring-white/10"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{CLOUD_PRICING_DATA.plans.map((plan) => (
|
||||
<PricingCard
|
||||
planPeriod={planPeriod}
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
onUpgrade={async () => {
|
||||
await onUpgrade(plan.id);
|
||||
}}
|
||||
organization={organization}
|
||||
productFeatureKeys={productFeatureKeys}
|
||||
onManageSubscription={openCustomerPortal}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
className={`items-centerrounded-md flex-1 whitespace-nowrap py-0.5 pl-4 pr-2 text-center ${
|
||||
planPeriod === "yearly" ? "bg-slate-200 font-semibold" : "bg-transparent"
|
||||
}`}
|
||||
onClick={() => handleMonthlyToggle("yearly")}>
|
||||
{t("environments.settings.billing.annually")}
|
||||
<span className="ml-2 inline-flex items-center rounded-full border border-green-200 bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800">
|
||||
{t("environments.settings.billing.get_2_months_free")} 🔥
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative mx-auto grid max-w-md grid-cols-1 gap-y-8 lg:mx-0 lg:-mb-14 lg:max-w-none lg:grid-cols-4">
|
||||
<div
|
||||
className="hidden lg:absolute lg:inset-x-px lg:bottom-0 lg:top-4 lg:block lg:rounded-xl lg:rounded-t-2xl lg:border lg:border-slate-200 lg:bg-slate-100 lg:pb-8 lg:ring-1 lg:ring-white/10"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{CLOUD_PRICING_DATA.plans.map((plan) => (
|
||||
<PricingCard
|
||||
planPeriod={planPeriod}
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
onUpgrade={async () => {
|
||||
await onUpgrade(plan.id);
|
||||
}}
|
||||
organization={organization}
|
||||
productFeatureKeys={productFeatureKeys}
|
||||
onManageSubscription={openCustomerPortal}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,10 @@ import { getTranslations } from "next-intl/server";
|
||||
import { notFound } from "next/navigation";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
|
||||
import { ErrorComponent } from "@formbricks/ui/components/ErrorComponent";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Billing",
|
||||
@@ -26,7 +29,10 @@ const BillingLayout = async ({ children, params }) => {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
|
||||
const { isMember } = getAccessFlags(currentUserMembership?.role);
|
||||
|
||||
return <>{!isMember ? <>{children}</> : <ErrorComponent />}</>;
|
||||
};
|
||||
|
||||
export default BillingLayout;
|
||||
|
||||
@@ -6,7 +6,6 @@ import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { PRODUCT_FEATURE_KEYS, STRIPE_PRICE_LOOKUP_KEYS } from "@formbricks/lib/constants";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import {
|
||||
getMonthlyActiveOrganizationPeopleCount,
|
||||
getMonthlyOrganizationResponseCount,
|
||||
@@ -34,8 +33,6 @@ const Page = async ({ params }) => {
|
||||
]);
|
||||
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
|
||||
const { isMember } = getAccessFlags(currentUserMembership?.role);
|
||||
const hasBillingRights = !isMember;
|
||||
|
||||
const canDoRoleManagement = await getRoleManagementPermission(organization);
|
||||
|
||||
@@ -58,7 +55,6 @@ const Page = async ({ params }) => {
|
||||
responseCount={responseCount}
|
||||
stripePriceLookupKeys={STRIPE_PRICE_LOOKUP_KEYS}
|
||||
productFeatureKeys={PRODUCT_FEATURE_KEYS}
|
||||
hasBillingRights={hasBillingRights}
|
||||
/>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { LegalFooter } from "@/app/s/[surveyId]/components/LegalFooter";
|
||||
import { SurveyLoadingAnimation } from "@/app/s/[surveyId]/components/SurveyLoadingAnimation";
|
||||
import { useState } from "react";
|
||||
import React, { useState } from "react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { TProduct, TProductStyling } from "@formbricks/types/product";
|
||||
import { TSurvey, TSurveyStyling } from "@formbricks/types/surveys/types";
|
||||
@@ -61,9 +61,9 @@ export const LinkSurveyWrapper = ({
|
||||
<div>
|
||||
<SurveyLoadingAnimation survey={survey} isBackgroundLoaded={isBackgroundLoaded} />
|
||||
<MediaBackground survey={survey} product={product} onBackgroundLoaded={handleBackgroundLoaded}>
|
||||
<div className="flex max-h-dvh min-h-dvh items-end justify-center overflow-clip sm:items-center">
|
||||
<div className="flex max-h-dvh min-h-dvh items-end justify-center overflow-clip md:items-center">
|
||||
{!styling.isLogoHidden && product.logo?.url && <ClientLogo product={product} />}
|
||||
<div className="h-full w-full space-y-6 p-0 sm:max-w-lg">
|
||||
<div className="h-full w-full space-y-6 p-0 md:max-w-md">
|
||||
{isPreview && (
|
||||
<div className="fixed left-0 top-0 flex w-full items-center justify-between bg-slate-600 p-2 px-4 text-center text-sm text-white shadow-sm">
|
||||
<div />
|
||||
|
||||
@@ -61,7 +61,7 @@ CacheHandler.onCreation(async () => {
|
||||
// Fallback to LRU handler if Redis client is not available.
|
||||
// The application will still work, but the cache will be in memory only and not shared.
|
||||
handler = createLruHandler();
|
||||
console.log("Using LRU handler for caching.");
|
||||
console.warn("Falling back to LRU handler because Redis client is not available.");
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -54,7 +54,7 @@ const getChannelTag = (
|
||||
|
||||
case 2:
|
||||
// Return labels for two channels concatenated with "or", removing "Survey"
|
||||
return labels.map(removeSurveySuffix).join(" " + t("common.or") + " ");
|
||||
return labels.map(removeSurveySuffix).join(t(" " + t("common.or") + " "));
|
||||
|
||||
case 3:
|
||||
return t("environments.surveys.templates.all_channels");
|
||||
@@ -76,14 +76,12 @@ export const TemplateTags = ({ template, selectedFilter }: TemplateTagsProps) =>
|
||||
const channelTag = useMemo(() => getChannelTag(template.channels, t), [template.channels]);
|
||||
const getIndustryTag = (industries: TProductConfigIndustry[] | undefined): string | undefined => {
|
||||
// if user selects an industry e.g. eCommerce than the tag should not say "Multiple industries" anymore but "E-Commerce".
|
||||
if (selectedFilter[1] !== null) {
|
||||
const industry = industryMapping.find((industry) => industry.value === selectedFilter[1]);
|
||||
if (industry) return t(industry.label);
|
||||
}
|
||||
if (selectedFilter[1] !== null)
|
||||
return industryMapping.find((industry) => industry.value === selectedFilter[1])?.label;
|
||||
if (!industries || industries.length === 0) return undefined;
|
||||
return industries.length > 1
|
||||
? t("environments.surveys.templates.multiple_industries")
|
||||
: t(industryMapping.find((industry) => industry.value === industries[0])?.label);
|
||||
: industryMapping.find((industry) => industry.value === industries[0])?.label;
|
||||
};
|
||||
|
||||
const industryTag = useMemo(
|
||||
@@ -97,7 +95,7 @@ export const TemplateTags = ({ template, selectedFilter }: TemplateTagsProps) =>
|
||||
{industryTag && (
|
||||
<div
|
||||
className={cn("rounded border border-slate-300 bg-slate-50 px-1.5 py-0.5 text-xs text-slate-500")}>
|
||||
{industryTag}
|
||||
{t(industryTag)}
|
||||
</div>
|
||||
)}
|
||||
{channelTag && (
|
||||
@@ -105,7 +103,7 @@ export const TemplateTags = ({ template, selectedFilter }: TemplateTagsProps) =>
|
||||
className={cn(
|
||||
"flex-nowrap rounded border border-slate-300 bg-slate-50 px-1.5 py-0.5 text-xs text-slate-500"
|
||||
)}>
|
||||
{channelTag}
|
||||
{t(channelTag)}
|
||||
</div>
|
||||
)}
|
||||
{template.preset.questions.some((question) => question.logic && question.logic.length > 0) && (
|
||||
|
||||
@@ -48,11 +48,7 @@ export const getAttributeClass = reactCache(
|
||||
);
|
||||
|
||||
export const getAttributeClasses = reactCache(
|
||||
async (
|
||||
environmentId: string,
|
||||
page?: number,
|
||||
options?: { skipArchived: boolean }
|
||||
): Promise<TAttributeClass[]> =>
|
||||
async (environmentId: string, page?: number): Promise<TAttributeClass[]> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([environmentId, ZId], [page, ZOptionalNumber]);
|
||||
@@ -61,7 +57,6 @@ export const getAttributeClasses = reactCache(
|
||||
const attributeClasses = await prisma.attributeClass.findMany({
|
||||
where: {
|
||||
environmentId: environmentId,
|
||||
...(options?.skipArchived ? { archived: false } : {}),
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "asc",
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
DEFAULT_ORGANIZATION_ID,
|
||||
DEFAULT_ORGANIZATION_ROLE,
|
||||
EMAIL_VERIFICATION_DISABLED,
|
||||
ENCRYPTION_KEY,
|
||||
GITHUB_ID,
|
||||
GITHUB_SECRET,
|
||||
GOOGLE_CLIENT_ID,
|
||||
@@ -26,7 +25,6 @@ import {
|
||||
OIDC_ISSUER,
|
||||
OIDC_SIGNING_ALGORITHM,
|
||||
} from "./constants";
|
||||
import { symmetricDecrypt, symmetricEncrypt } from "./crypto";
|
||||
import { verifyToken } from "./jwt";
|
||||
import { createMembership } from "./membership/service";
|
||||
import { createOrganization, getOrganization } from "./organization/service";
|
||||
@@ -54,8 +52,6 @@ export const authOptions: NextAuthOptions = {
|
||||
type: "password",
|
||||
placeholder: "Your password",
|
||||
},
|
||||
totpCode: { label: "Two-factor Code", type: "input", placeholder: "Code from authenticator app" },
|
||||
backupCode: { label: "Backup Code", type: "input", placeholder: "Two-factor backup code" },
|
||||
},
|
||||
async authorize(credentials, _req) {
|
||||
let user;
|
||||
@@ -83,54 +79,6 @@ export const authOptions: NextAuthOptions = {
|
||||
throw new Error("Invalid credentials");
|
||||
}
|
||||
|
||||
if (user.twoFactorEnabled && credentials.backupCode) {
|
||||
if (!ENCRYPTION_KEY) {
|
||||
console.error("Missing encryption key; cannot proceed with backup code login.");
|
||||
throw new Error("Internal Server Error");
|
||||
}
|
||||
|
||||
if (!user.backupCodes) throw new Error("No backup codes found");
|
||||
|
||||
const backupCodes = JSON.parse(symmetricDecrypt(user.backupCodes, ENCRYPTION_KEY));
|
||||
|
||||
// check if user-supplied code matches one
|
||||
const index = backupCodes.indexOf(credentials.backupCode.replaceAll("-", ""));
|
||||
if (index === -1) throw new Error("Invalid backup code");
|
||||
|
||||
// delete verified backup code and re-encrypt remaining
|
||||
backupCodes[index] = null;
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
backupCodes: symmetricEncrypt(JSON.stringify(backupCodes), ENCRYPTION_KEY),
|
||||
},
|
||||
});
|
||||
} else if (user.twoFactorEnabled) {
|
||||
if (!credentials.totpCode) {
|
||||
throw new Error("second factor required");
|
||||
}
|
||||
|
||||
if (!user.twoFactorSecret) {
|
||||
throw new Error("Internal Server Error");
|
||||
}
|
||||
|
||||
if (!ENCRYPTION_KEY) {
|
||||
throw new Error("Internal Server Error");
|
||||
}
|
||||
|
||||
const secret = symmetricDecrypt(user.twoFactorSecret, ENCRYPTION_KEY);
|
||||
if (secret.length !== 32) {
|
||||
throw new Error("Internal Server Error");
|
||||
}
|
||||
|
||||
const isValidToken = (await import("./totp")).totpAuthenticatorCheck(credentials.totpCode, secret);
|
||||
if (!isValidToken) {
|
||||
throw new Error("Invalid second factor code");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
|
||||
@@ -350,7 +350,6 @@
|
||||
"status": "Status",
|
||||
"step_by_step_manual": "Schritt-für-Schritt-Anleitung",
|
||||
"styling": "Styling",
|
||||
"submit": "Abschicken",
|
||||
"summary": "Zusammenfassung",
|
||||
"survey": "Umfrage",
|
||||
"survey_completed": "Umfrage abgeschlossen.",
|
||||
@@ -488,8 +487,6 @@
|
||||
},
|
||||
"environments": {
|
||||
"actions": {
|
||||
"action_copied_successfully": "Aktion erfolgreich kopiert",
|
||||
"action_copy_failed": "Aktion konnte nicht kopiert werden",
|
||||
"action_created_successfully": "Aktion erfolgreich erstellt",
|
||||
"action_deleted_successfully": "Aktion erfolgreich gelöscht",
|
||||
"action_type": "Aktionstyp",
|
||||
@@ -1136,7 +1133,6 @@
|
||||
"disable_two_factor_authentication": "Zwei-Faktor-Authentifizierung deaktivieren",
|
||||
"disable_two_factor_authentication_description": "Wenn Du die Zwei-Faktor-Authentifizierung deaktivieren musst, empfehlen wir, sie so schnell wie möglich wieder zu aktivieren.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Jeder Backup-Code kann genau einmal verwendet werden, um Zugang ohne deinen Authenticator zu gewähren.",
|
||||
"enable_two_factor_authentication": "Zwei-Faktor-Authentifizierung aktivieren",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Gib den Code aus deiner Authentifizierungs-App unten ein.",
|
||||
"file_size_must_be_less_than_10mb": "Dateigröße muss weniger als 10MB sein.",
|
||||
"invalid_file_type": "Ungültiger Dateityp. Nur JPEG-, PNG- und WEBP-Dateien sind erlaubt.",
|
||||
@@ -1576,7 +1572,7 @@
|
||||
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Warte ein paar Sekunden nach dem Auslöser, bevor Du die Umfrage anzeigst",
|
||||
"waiting_period": "Wartezeit",
|
||||
"welcome_message": "Willkommensnachricht",
|
||||
"when": "Wenn",
|
||||
"when": "Wann",
|
||||
"when_conditions_match_waiting_time_will_be_ignored_and_survey_shown": "Wenn die Bedingungen übereinstimmen, wird die Wartezeit ignoriert und die Umfrage angezeigt.",
|
||||
"with_the_formbricks_sdk": "mit dem Formbricks SDK",
|
||||
"without_a_filter_all_of_your_users_can_be_surveyed": "Ohne Filter können alle deine Nutzer befragt werden.",
|
||||
@@ -1699,11 +1695,7 @@
|
||||
"is_equal_to": "Ist gleich",
|
||||
"is_less_than": "ist weniger als",
|
||||
"last_30_days": "Letzte 30 Tage",
|
||||
"last_6_months": "Letzte 6 Monate",
|
||||
"last_7_days": "Letzte 7 Tage",
|
||||
"last_month": "Letztes Monat",
|
||||
"last_quarter": "Letztes Quartal",
|
||||
"last_year": "Letztes Jahr",
|
||||
"learn_how_to": "Lerne, wie man",
|
||||
"link_to_public_results_copied": "Link zu öffentlichen Ergebnissen kopiert",
|
||||
"make_sure_the_survey_type_is_set_to": "Stelle sicher, dass der Umfragetyp richtig eingestellt ist",
|
||||
@@ -1736,9 +1728,6 @@
|
||||
"static_iframe": "Statisch (iframe)",
|
||||
"survey_results_are_public": "Deine Umfrageergebnisse sind öffentlich",
|
||||
"survey_results_are_shared_with_anyone_who_has_the_link": "Deine Umfrageergebnisse stehen allen zur Verfügung, die den Link haben. Die Ergebnisse werden nicht von Suchmaschinen indexiert.",
|
||||
"this_month": "Dieser Monat",
|
||||
"this_quarter": "Dieses Quartal",
|
||||
"this_year": "Dieses Jahr",
|
||||
"time_to_complete": "Zeit zur Fertigstellung",
|
||||
"to_connect_your_app_with_formbricks": "um deine App mit Formbricks zu verbinden",
|
||||
"to_connect_your_web_app_with_formbricks": "um deine Web-App mit Formbricks zu verbinden",
|
||||
|
||||
@@ -350,7 +350,6 @@
|
||||
"status": "Status",
|
||||
"step_by_step_manual": "Step by step manual",
|
||||
"styling": "Styling",
|
||||
"submit": "Submit",
|
||||
"summary": "Summary",
|
||||
"survey": "Survey",
|
||||
"survey_completed": "Survey completed.",
|
||||
@@ -488,8 +487,6 @@
|
||||
},
|
||||
"environments": {
|
||||
"actions": {
|
||||
"action_copied_successfully": "Action copied successfully",
|
||||
"action_copy_failed": "Action copy failed",
|
||||
"action_created_successfully": "Action created successfully",
|
||||
"action_deleted_successfully": "Action deleted successfully",
|
||||
"action_type": "Action Type",
|
||||
@@ -1136,7 +1133,6 @@
|
||||
"disable_two_factor_authentication": "Disable two factor authentication",
|
||||
"disable_two_factor_authentication_description": "If you need to disable 2FA, we recommend re-enabling it as soon as possible.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Each backup code can be used exactly once to grant access without your authenticator.",
|
||||
"enable_two_factor_authentication": "Enable two factor authentication",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Enter the code from your authenticator app below.",
|
||||
"file_size_must_be_less_than_10mb": "File size must be less than 10MB.",
|
||||
"invalid_file_type": "Invalid file type. Only JPEG, PNG, and WEBP files are allowed.",
|
||||
@@ -1699,11 +1695,7 @@
|
||||
"is_equal_to": "Is equal to",
|
||||
"is_less_than": "Is less than",
|
||||
"last_30_days": "Last 30 days",
|
||||
"last_6_months": "Last 6 months",
|
||||
"last_7_days": "Last 7 days",
|
||||
"last_month": "Last month",
|
||||
"last_quarter": "Last quarter",
|
||||
"last_year": "Last year",
|
||||
"learn_how_to": "Learn how to",
|
||||
"link_to_public_results_copied": "Link to public results copied",
|
||||
"make_sure_the_survey_type_is_set_to": "Make sure the survey type is set to",
|
||||
@@ -1736,9 +1728,6 @@
|
||||
"static_iframe": "Static (iframe)",
|
||||
"survey_results_are_public": "Your survey results are public!",
|
||||
"survey_results_are_shared_with_anyone_who_has_the_link": "Your survey results are shared with anyone who has the link. The results will not be indexed by search engines.",
|
||||
"this_month": "This month",
|
||||
"this_quarter": "This quarter",
|
||||
"this_year": "This year",
|
||||
"time_to_complete": "Time to Complete",
|
||||
"to_connect_your_app_with_formbricks": "to connect your app with Formbricks",
|
||||
"to_connect_your_web_app_with_formbricks": "to connect your web app with Formbricks",
|
||||
|
||||
@@ -350,7 +350,6 @@
|
||||
"status": "status",
|
||||
"step_by_step_manual": "Manual passo a passo",
|
||||
"styling": "estilização",
|
||||
"submit": "Enviar",
|
||||
"summary": "Resumo",
|
||||
"survey": "Pesquisa",
|
||||
"survey_completed": "Pesquisa concluída.",
|
||||
@@ -488,8 +487,6 @@
|
||||
},
|
||||
"environments": {
|
||||
"actions": {
|
||||
"action_copied_successfully": "Ação copiada com sucesso",
|
||||
"action_copy_failed": "Falha ao copiar a ação",
|
||||
"action_created_successfully": "Ação criada com sucesso",
|
||||
"action_deleted_successfully": "Ação deletada com sucesso",
|
||||
"action_type": "Tipo de Ação",
|
||||
@@ -1136,7 +1133,6 @@
|
||||
"disable_two_factor_authentication": "Desativar a autenticação de dois fatores",
|
||||
"disable_two_factor_authentication_description": "Se você precisar desativar a 2FA, recomendamos reativá-la o mais rápido possível.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Cada código de backup pode ser usado exatamente uma vez para conceder acesso sem o seu autenticador.",
|
||||
"enable_two_factor_authentication": "Ativar autenticação de dois fatores",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Digite o código do seu app autenticador abaixo.",
|
||||
"file_size_must_be_less_than_10mb": "O tamanho do arquivo deve ser menor que 10MB.",
|
||||
"invalid_file_type": "Tipo de arquivo inválido. Só são permitidos arquivos JPEG, PNG e WEBP.",
|
||||
@@ -1699,11 +1695,7 @@
|
||||
"is_equal_to": "É igual a",
|
||||
"is_less_than": "É menor que",
|
||||
"last_30_days": "Últimos 30 dias",
|
||||
"last_6_months": "Últimos 6 meses",
|
||||
"last_7_days": "Últimos 7 dias",
|
||||
"last_month": "Último mês",
|
||||
"last_quarter": "Último trimestre",
|
||||
"last_year": "Último ano",
|
||||
"learn_how_to": "Aprenda como",
|
||||
"link_to_public_results_copied": "Link pros resultados públicos copiado",
|
||||
"make_sure_the_survey_type_is_set_to": "Certifique-se de que o tipo de pesquisa esteja definido como",
|
||||
@@ -1736,9 +1728,6 @@
|
||||
"static_iframe": "Estático (iframe)",
|
||||
"survey_results_are_public": "Os resultados da sua pesquisa são públicos!",
|
||||
"survey_results_are_shared_with_anyone_who_has_the_link": "Os resultados da sua pesquisa são compartilhados com quem tiver o link. Os resultados não serão indexados por motores de busca.",
|
||||
"this_month": "Este mês",
|
||||
"this_quarter": "Este trimestre",
|
||||
"this_year": "Este ano",
|
||||
"time_to_complete": "Tempo para Concluir",
|
||||
"to_connect_your_app_with_formbricks": "conectar seu app com o Formbricks",
|
||||
"to_connect_your_web_app_with_formbricks": "conectar seu app web com o Formbricks",
|
||||
|
||||
@@ -409,7 +409,7 @@ export const Survey = ({
|
||||
<AutoCloseWrapper survey={localSurvey} onClose={onClose} offset={offset}>
|
||||
<div
|
||||
className={cn(
|
||||
"fb-no-scrollbar sm:fb-rounded-custom fb-rounded-t-custom fb-bg-survey-bg fb-flex fb-h-full fb-w-full fb-flex-col fb-justify-between fb-overflow-hidden fb-transition-all fb-duration-1000 fb-ease-in-out",
|
||||
"fb-no-scrollbar md:fb-rounded-custom fb-rounded-t-custom fb-bg-survey-bg fb-flex fb-h-full fb-w-full fb-flex-col fb-justify-between fb-overflow-hidden fb-transition-all fb-duration-1000 fb-ease-in-out",
|
||||
cardArrangement === "simple" ? "fb-survey-shadow" : "",
|
||||
offset === 0 || cardArrangement === "simple" ? "fb-opacity-100" : "fb-opacity-0"
|
||||
)}>
|
||||
@@ -430,7 +430,7 @@ export const Survey = ({
|
||||
)}>
|
||||
{content()}
|
||||
</div>
|
||||
<div className="fb-mx-6 fb-mb-10 fb-mt-2 fb-space-y-3 sm:fb-mb-6 sm:fb-mt-6">
|
||||
<div className="fb-mx-6 fb-mb-10 fb-mt-2 fb-space-y-3 md:fb-mb-6 md:fb-mt-6">
|
||||
{isBrandingEnabled && <FormbricksBranding />}
|
||||
{showProgressBar && <ProgressBar survey={localSurvey} questionId={questionId} />}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { ZOrganizationRole } from "./memberships";
|
||||
import { ZUserName } from "./user";
|
||||
|
||||
export const ZInvite = z.object({
|
||||
id: z.string(),
|
||||
@@ -17,7 +16,7 @@ export type TInvite = z.infer<typeof ZInvite>;
|
||||
|
||||
export const ZInvitee = z.object({
|
||||
email: z.string().email(),
|
||||
name: ZUserName,
|
||||
name: z.string(),
|
||||
role: ZOrganizationRole,
|
||||
});
|
||||
export type TInvitee = z.infer<typeof ZInvitee>;
|
||||
|
||||
@@ -22,17 +22,14 @@ export const ZUserNotificationSettings = z.object({
|
||||
unsubscribedOrganizationIds: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export const ZUserName = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, { message: "Name should be at least 1 character long" })
|
||||
.regex(/^[a-zA-Z0-9\s]+$/, { message: "Name should only contain letters, numbers, and spaces" });
|
||||
|
||||
export type TUserNotificationSettings = z.infer<typeof ZUserNotificationSettings>;
|
||||
|
||||
export const ZUser = z.object({
|
||||
id: z.string(),
|
||||
name: ZUserName,
|
||||
name: z
|
||||
.string({ message: "Name is required" })
|
||||
.trim()
|
||||
.min(1, { message: "Name should be at least 1 character long" }),
|
||||
email: z.string().email(),
|
||||
emailVerified: z.date().nullable(),
|
||||
imageUrl: z.string().url().nullable(),
|
||||
@@ -49,7 +46,7 @@ export const ZUser = z.object({
|
||||
export type TUser = z.infer<typeof ZUser>;
|
||||
|
||||
export const ZUserUpdateInput = z.object({
|
||||
name: ZUserName.optional(),
|
||||
name: z.string().optional(),
|
||||
email: z.string().email().optional(),
|
||||
emailVerified: z.date().nullish(),
|
||||
role: ZRole.optional(),
|
||||
@@ -62,7 +59,10 @@ export const ZUserUpdateInput = z.object({
|
||||
export type TUserUpdateInput = z.infer<typeof ZUserUpdateInput>;
|
||||
|
||||
export const ZUserCreateInput = z.object({
|
||||
name: ZUserName,
|
||||
name: z
|
||||
.string({ message: "Name is required" })
|
||||
.trim()
|
||||
.min(1, { message: "Name should be at least 1 character long" }),
|
||||
email: z.string().email(),
|
||||
emailVerified: z.date().optional(),
|
||||
role: ZRole.optional(),
|
||||
|
||||
@@ -385,7 +385,7 @@ export const PreviewSurvey = ({
|
||||
<ClientLogo environmentId={environment.id} product={product} previewSurvey />
|
||||
)}
|
||||
</div>
|
||||
<div className="z-0 w-full max-w-lg rounded-lg border-transparent">
|
||||
<div className="z-0 w-full max-w-md rounded-lg border-transparent">
|
||||
<SurveyInline
|
||||
survey={{ ...survey, type: "link" }}
|
||||
isBrandingEnabled={product.linkSurveyBranding}
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRef, useState } from "react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { createUser } from "@formbricks/lib/utils/users";
|
||||
import { ZUserName } from "@formbricks/types/user";
|
||||
import { Button } from "../Button";
|
||||
import { FormControl, FormError, FormField, FormItem } from "../Form";
|
||||
import { Input } from "../Input";
|
||||
import { PasswordInput } from "../PasswordInput";
|
||||
import { AzureButton } from "./components/AzureButton";
|
||||
import { GithubButton } from "./components/GithubButton";
|
||||
@@ -47,35 +41,28 @@ export const SignupOptions = ({
|
||||
oidcDisplayName,
|
||||
userLocale,
|
||||
}: SignupOptionsProps) => {
|
||||
const t = useTranslations();
|
||||
const [password, setPassword] = useState<string | null>(null);
|
||||
const [showLogin, setShowLogin] = useState(false);
|
||||
const [isValid, setIsValid] = useState(false);
|
||||
const [signingUp, setSigningUp] = useState(false);
|
||||
const t = useTranslations();
|
||||
|
||||
const ZSignupInput = z.object({
|
||||
name: ZUserName,
|
||||
email: z.string().email(),
|
||||
password: z
|
||||
.string()
|
||||
.min(8)
|
||||
.regex(/^(?=.*[A-Z])(?=.*\d).*$/),
|
||||
});
|
||||
|
||||
type TSignupInput = z.infer<typeof ZSignupInput>;
|
||||
const form = useForm<TSignupInput>({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
email: emailFromSearchParams || "",
|
||||
password: "",
|
||||
},
|
||||
resolver: zodResolver(ZSignupInput),
|
||||
});
|
||||
const [isButtonEnabled, setButtonEnabled] = useState(true);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const nameRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleSubmit = async (data: TSignupInput) => {
|
||||
const checkFormValidity = () => {
|
||||
// If all fields are filled, enable the button
|
||||
if (formRef.current) {
|
||||
setButtonEnabled(formRef.current.checkValidity());
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: any) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!isValid) {
|
||||
return;
|
||||
}
|
||||
@@ -83,10 +70,16 @@ export const SignupOptions = ({
|
||||
setSigningUp(true);
|
||||
|
||||
try {
|
||||
await createUser(data.name, data.email, data.password, userLocale, inviteToken || "");
|
||||
await createUser(
|
||||
e.target.elements.name.value,
|
||||
e.target.elements.email.value,
|
||||
e.target.elements.password.value,
|
||||
userLocale,
|
||||
inviteToken || ""
|
||||
);
|
||||
const url = emailVerificationDisabled
|
||||
? `/auth/signup-without-verification-success`
|
||||
: `/auth/verification-requested?email=${encodeURIComponent(data.email)}`;
|
||||
: `/auth/verification-requested?email=${encodeURIComponent(e.target.elements.email.value)}`;
|
||||
|
||||
router.push(url);
|
||||
} catch (e: any) {
|
||||
@@ -100,105 +93,87 @@ export const SignupOptions = ({
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{emailAuthEnabled && (
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)}>
|
||||
{showLogin && (
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
<form onSubmit={handleSubmit} ref={formRef} className="space-y-2" onChange={checkFormValidity}>
|
||||
{showLogin && (
|
||||
<div>
|
||||
<div className="mb-2 transition-all duration-500 ease-in-out">
|
||||
<label htmlFor="name" className="sr-only">
|
||||
{t("common.full_name")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
ref={nameRef}
|
||||
id="name"
|
||||
name="name"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<div>
|
||||
<Input
|
||||
value={field.value}
|
||||
name="name"
|
||||
autoFocus
|
||||
onChange={(name) => field.onChange(name)}
|
||||
placeholder="Full name"
|
||||
className="bg-white"
|
||||
/>
|
||||
{error?.message && <FormError className="text-left">{error.message}</FormError>}
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<div>
|
||||
<Input
|
||||
value={field.value}
|
||||
name="email"
|
||||
onChange={(email) => field.onChange(email)}
|
||||
defaultValue={emailFromSearchParams}
|
||||
placeholder="work@email.com"
|
||||
className="bg-white"
|
||||
/>
|
||||
{error?.message && <FormError className="text-left">{error.message}</FormError>}
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<div>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
name="password"
|
||||
value={field.value}
|
||||
onChange={(password) => field.onChange(password)}
|
||||
autoComplete="current-password"
|
||||
placeholder="*******"
|
||||
aria-placeholder="password"
|
||||
required
|
||||
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md shadow-sm sm:text-sm"
|
||||
/>
|
||||
{error?.message && <FormError className="text-left">{error.message}</FormError>}
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
type="text"
|
||||
autoComplete="given-name"
|
||||
placeholder={t("common.full_name")}
|
||||
aria-placeholder={"Full name"}
|
||||
required
|
||||
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<IsPasswordValid password={form.watch("password")} setIsValid={setIsValid} />
|
||||
</div>
|
||||
)}
|
||||
{showLogin && (
|
||||
<Button
|
||||
type="submit"
|
||||
className="h-10 w-full justify-center"
|
||||
loading={signingUp}
|
||||
disabled={!form.formState.isValid}>
|
||||
{t("auth.continue_with_email")}
|
||||
</Button>
|
||||
)}
|
||||
<div className="mb-2 transition-all duration-500 ease-in-out">
|
||||
<label htmlFor="email" className="sr-only">
|
||||
{t("common.email")}
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
placeholder="work@email.com"
|
||||
defaultValue={emailFromSearchParams}
|
||||
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="transition-all duration-500 ease-in-out">
|
||||
<label htmlFor="password" className="sr-only">
|
||||
{t("common.password")}
|
||||
</label>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
name="password"
|
||||
value={password ? password : ""}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
placeholder="*******"
|
||||
aria-placeholder="password"
|
||||
required
|
||||
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md shadow-sm sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<IsPasswordValid password={password} setIsValid={setIsValid} />
|
||||
</div>
|
||||
)}
|
||||
{showLogin && (
|
||||
<Button
|
||||
size="base"
|
||||
type="submit"
|
||||
className="w-full justify-center"
|
||||
loading={signingUp}
|
||||
disabled={formRef.current ? !isButtonEnabled || !isValid : !isButtonEnabled}>
|
||||
{t("auth.continue_with_email")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!showLogin && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowLogin(true);
|
||||
// Add a slight delay before focusing the input field to ensure it's visible
|
||||
setTimeout(() => nameRef.current?.focus(), 100);
|
||||
}}
|
||||
className="h-10 w-full justify-center">
|
||||
{t("auth.continue_with_email")}
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
</FormProvider>
|
||||
{!showLogin && (
|
||||
<Button
|
||||
size="base"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowLogin(true);
|
||||
setButtonEnabled(false);
|
||||
// Add a slight delay before focusing the input field to ensure it's visible
|
||||
setTimeout(() => nameRef.current?.focus(), 100);
|
||||
}}
|
||||
className="w-full justify-center">
|
||||
{t("auth.continue_with_email")}
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
{googleOAuthEnabled && (
|
||||
<>
|
||||
|
||||
@@ -108,7 +108,6 @@
|
||||
"INVITE_DISABLED",
|
||||
"IS_FORMBRICKS_CLOUD",
|
||||
"MAIL_FROM",
|
||||
"NEXT_PUBLIC_LAYER_API_KEY",
|
||||
"NEXT_PUBLIC_DOCSEARCH_APP_ID",
|
||||
"NEXT_PUBLIC_DOCSEARCH_API_KEY",
|
||||
"NEXT_PUBLIC_DOCSEARCH_INDEX_NAME",
|
||||
|
||||
Reference in New Issue
Block a user