Merge branch 'main' into fix/discord-webhook-not-pinging

This commit is contained in:
Kartik Saini
2025-02-05 12:54:59 +05:30
committed by GitHub
177 changed files with 10413 additions and 4403 deletions

View File

@@ -1,11 +1,11 @@
name: SonarQube
on:
workflow_dispatch:
# push:
# branches:
# - main
# pull_request:
# types: [opened, synchronize, reopened]
push:
branches:
- main
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
jobs:
@@ -16,6 +16,35 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: Setup Node.js 20.x
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
with:
node-version: 20.x
- name: Install pnpm
uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
- name: create .env
run: cp .env.example .env
- name: Generate Random ENCRYPTION_KEY, CRON_SECRET & NEXTAUTH_SECRET and fill in .env
run: |
RANDOM_KEY=$(openssl rand -hex 32)
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
- name: Run tests with coverage
run: |
cd apps/web
pnpm test:coverage
cd ../../
# The Vitest coverage config is in your vite.config.mts
- name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@bfd4e558cda28cda6b5defafb9232d191be8c203
env:

View File

@@ -1,2 +1,2 @@
EXPO_PUBLIC_API_HOST=http://192.168.178.20:3000
EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID=clzr04nkd000bcdl110j0ijyq
EXPO_PUBLIC_APP_URL=http://192.168.0.197:3000
EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID=cm5p0cs7r000819182b32j0a1

View File

@@ -18,6 +18,7 @@
},
"jsEngine": "hermes",
"name": "react-native-demo",
"newArchEnabled": true,
"orientation": "portrait",
"slug": "react-native-demo",
"splash": {

View File

@@ -13,16 +13,17 @@
"dependencies": {
"@formbricks/js": "workspace:*",
"@formbricks/react-native": "workspace:*",
"expo": "52.0.18",
"expo-status-bar": "2.0.0",
"@react-native-async-storage/async-storage": "2.1.0",
"expo": "52.0.28",
"expo-status-bar": "2.0.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-native": "0.76.5",
"react-native": "0.76.6",
"react-native-webview": "13.12.5"
},
"devDependencies": {
"@babel/core": "7.26.0",
"@types/react": "19.0.1",
"@types/react": "18.3.18",
"typescript": "5.7.2"
},
"private": true

View File

@@ -1,7 +1,14 @@
import { StatusBar } from "expo-status-bar";
import React, { type JSX } from "react";
import { Button, LogBox, StyleSheet, Text, View } from "react-native";
import Formbricks, { track } from "@formbricks/react-native";
import Formbricks, {
logout,
setAttribute,
setAttributes,
setLanguage,
setUserId,
track,
} from "@formbricks/react-native";
LogBox.ignoreAllLogs();
@@ -10,35 +17,92 @@ export default function App(): JSX.Element {
throw new Error("EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID is required");
}
if (!process.env.EXPO_PUBLIC_API_HOST) {
throw new Error("EXPO_PUBLIC_API_HOST is required");
if (!process.env.EXPO_PUBLIC_APP_URL) {
throw new Error("EXPO_PUBLIC_APP_URL is required");
}
const config = {
environmentId: process.env.EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID as string,
apiHost: process.env.EXPO_PUBLIC_API_HOST as string,
userId: "random-user-id",
attributes: {
language: "en",
testAttr: "attr-test",
},
};
return (
<View style={styles.container}>
<Text>Formbricks React Native SDK Demo</Text>
<Button
title="Trigger Code Action"
onPress={() => {
track("code").catch((error: unknown) => {
// eslint-disable-next-line no-console -- logging is allowed in demo apps
console.error("Error tracking event:", error);
});
}}
/>
<View
style={{
display: "flex",
flexDirection: "column",
gap: 10,
}}>
<Button
title="Trigger Code Action"
onPress={() => {
track("code").catch((error: unknown) => {
// eslint-disable-next-line no-console -- logging is allowed in demo apps
console.error("Error tracking event:", error);
});
}}
/>
<Button
title="Set User Id"
onPress={() => {
setUserId("random-user-id").catch((error: unknown) => {
// eslint-disable-next-line no-console -- logging is allowed in demo apps
console.error("Error setting user id:", error);
});
}}
/>
<Button
title="Set User Attributess (multiple)"
onPress={() => {
setAttributes({
testAttr: "attr-test",
testAttr2: "attr-test-2",
testAttr3: "attr-test-3",
testAttr4: "attr-test-4",
}).catch((error: unknown) => {
// eslint-disable-next-line no-console -- logging is allowed in demo apps
console.error("Error setting user attributes:", error);
});
}}
/>
<Button
title="Set User Attributes (single)"
onPress={() => {
setAttribute("testSingleAttr", "testSingleAttr").catch((error: unknown) => {
// eslint-disable-next-line no-console -- logging is allowed in demo apps
console.error("Error setting user attributes:", error);
});
}}
/>
<Button
title="Logout"
onPress={() => {
logout().catch((error: unknown) => {
// eslint-disable-next-line no-console -- logging is allowed in demo apps
console.error("Error logging out:", error);
});
}}
/>
<Button
title="Set Language (de)"
onPress={() => {
setLanguage("de").catch((error: unknown) => {
// eslint-disable-next-line no-console -- logging is allowed in demo apps
console.error("Error setting language:", error);
});
}}
/>
</View>
<StatusBar style="auto" />
<Formbricks initConfig={config} />
<Formbricks
appUrl={process.env.EXPO_PUBLIC_APP_URL as string}
environmentId={process.env.EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID as string}
/>
</View>
);
}

View File

@@ -357,17 +357,11 @@ Now, update your App.js/App.tsx file to initialize Formbricks:
// other imports
import Formbricks from "@formbricks/react-native";
const config = {
environmentId: "<environment-id>",
apiHost: "<api-host>",
userId: "<user-id>", // optional
};
export default function App() {
return (
<>
{/* Your app content */}
<Formbricks initConfig={config} />
<Formbricks appUrl="https://app.formbricks.com" environmentId="your-environment-id" />
</>
);
}
@@ -381,7 +375,7 @@ export default function App() {
<Property name="environment-id" type="string">
Formbricks Environment ID.
</Property>
<Property name="api-host" type="string">
<Property name="app-url" type="string">
URL of the hosted Formbricks instance.
</Property>
</Properties>

View File

@@ -35,33 +35,41 @@ tags:
sessions, and responses within the Formbricks platform. This API is ideal
for client-side interactions, as it doesn't expose sensitive information.
- [Actions
API](https://go.postman.co/workspace/90ba379a-0de2-47d2-a94c-8eb541e47082/documentation/11026000-927c954f-85a9-4f8f-b0ec-14191b903737?entity=folder-b8f3a10e-1642-4d82-a629-fef0a8c6c86c)
- Create actions for a person
- [Displays API](https://formbricks.com/docs/api/client/displays) - Mark
- Displays API - Mark
Survey as Displayed or Responded for a Person
- [People API](https://formbricks.com/docs/api/client/people) - Create &
update people (e.g. attributes)
- Responses API - Create & update responses for a survey
- Environment API - Get the environment state to be used in Formbricks SDKs
- Contacts API - Identify & update contacts (e.g. attributes)
- User API - Identify & track users based on their attributes, device type, etc.
- [Responses API](https://formbricks.com/docs/api/client/responses) -
Create & update responses for a survey
- name: Client API > Display
description: >-
Displays are metrics used to measure the number of times a survey was
viewed both by unidentified or identified users.
- name: Client API > People
description: >-
Persons are the identified users on Formbricks app that get initated when
you pass a userId and have user activation enabled. This now allows you to
track & show them targeted surveys based on their actions, attributes,
etc.
- name: Client API > Response
description: >-
Responses are captured whenever a user fills in your survey either
partially or completely.
- name: Client API > Environment
description: >-
Get the environment state to be used in Formbricks SDKs
- name: Client API > Contacts
description: >-
Contacts are the identified users on Formbricks app that get initated when
you pass a userId and have user activation enabled. This now allows you to
track & show them targeted surveys based on their attributes, device type,
etc.
- name: Client API > User
description: >-
Users are the identified users on Formbricks app that get initated when
you pass a userId and have user activation enabled. This now allows you to
track & show them targeted surveys based on their attributes, device type,
etc. Currently, this api is only being used in the react-native sdk.
- name: Management API
description: "The Management API provides access to all data and settings that are visible in the Formbricks App. This API requires a personal API Key for authentication, which can be generated in the Settings section of the Formbricks App. With the Management API, you can manage your Formbricks account programmatically, accessing and modifying data and settings as needed.\n\n> **For Auth:**\_we use the `x-api-key` header \n \n\nAPI requests made to the Management API are authorized using a personal API key. This key grants the same rights and access as if you were logged in at formbricks.com. It's essential to keep your API key secure and not share it with others.\n\nTo generate, store, or delete an API key, follow the instructions provided on the following page\_[API Key](https://formbricks.com/docs/api/management/api-key-setup)."
- name: Management API > Action Class
@@ -436,139 +444,424 @@ paths:
code: internal_server_error
message: Person with ID 2 not found
details: {}
/api/v1/client/{environmentId}/people:
post:
/api/v1/client/{environmentId}/environment:
get:
tags:
- Client API > People
summary: Create Person
description: Creates a person in Formbricks with the given userId, it must be unique.
requestBody:
content:
application/json:
schema:
type: object
example:
userId: "{{userId}}"
- Client API > Environment
summary: Get Environment State
description: >-
Retrieves the environment state to be used in Formbricks SDKs
parameters:
- name: environmentId
in: path
required: true
schema:
type: string
description: The ID of the environment
responses:
"200":
description: "HTTP Status 200"
content:
application/json:
schema:
type: object
properties:
data:
type: object
properties:
surveys:
type: array
description: List of surveys in the environment
items:
type: object
properties:
id:
type: string
example: "cm6orr901000g19wjwwa690eo"
welcomeCard:
type: object
properties:
html:
type: string
example: "Thanks for providing your feedback - let's go!"
enabled:
type: boolean
example: false
headline:
type: string
example: "Welcome!"
buttonLabel:
type: string
example: "Next"
timeToFinish:
type: boolean
example: false
showResponseCount:
type: boolean
example: false
name:
type: string
example: "Start from scratch"
questions:
type: array
items:
type: object
properties:
id:
type: string
example: "dd5c8w2a4ttkbnjb9nwhtb17"
type:
type: string
example: "openText"
headline:
type: string
example: "What would you like to know?"
required:
type: boolean
example: true
charLimit:
type: boolean
example: false
inputType:
type: string
example: "text"
buttonLabel:
type: string
example: "Next"
placeholder:
type: string
example: "Type your answer here..."
variables:
type: array
items:
type: string
type:
type: string
example: "app"
showLanguageSwitch:
type: boolean
example: null
languages:
type: array
items:
type: string
endings:
type: array
items:
type: object
properties:
id:
type: string
example: "o729tod5klhix62njmk262dk"
type:
type: string
example: "endScreen"
headline:
type: string
example: "Thank you!"
subheader:
type: string
example: "We appreciate your feedback."
buttonLink:
type: string
example: "https://formbricks.com"
buttonLabel:
type: string
example: "Create your own Survey"
autoClose:
type: boolean
example: null
styling:
type: object
example: null
status:
type: string
example: "inProgress"
segment:
type: object
properties:
id:
type: string
example: "cm6orr90h000h19wj1lnwoxwg"
createdAt:
type: string
example: "2025-02-03T08:08:33.377Z"
updatedAt:
type: string
example: "2025-02-03T08:08:33.377Z"
title:
type: string
example: "cm6orr901000g19wjwwa690eo"
description:
type: string
example: null
isPrivate:
type: boolean
example: true
filters:
type: array
items:
type: string
recontactDays:
type: integer
example: 0
displayLimit:
type: integer
example: 5
displayOption:
type: string
example: "respondMultiple"
hiddenFields:
type: object
properties:
enabled:
type: boolean
example: true
fieldIds:
type: array
items:
type: string
triggers:
type: array
items:
type: object
properties:
actionClass:
type: string
example: "code action"
displayPercentage:
type: integer
example: null
delay:
type: integer
example: 0
actionClasses:
type: array
items:
type: object
properties:
id:
type: string
example: "cm6orqtdd000b19wjec82bpp2"
type:
type: string
example: "automatic"
name:
type: string
example: "New Session"
key:
type: string
nullable: true
example: null
noCodeConfig:
type: object
nullable: true
example: null
example:
- id: "cm6orqtdd000b19wjec82bpp2"
type: "automatic"
name: "New Session"
key: null
noCodeConfig: null
- id: "cm6oryki3000i19wj860utcnn"
type: "code"
name: "code action"
key: "code"
noCodeConfig: null
project:
type: object
properties:
id:
type: string
example: "cm6orqtcl000319wj9wb7dltl"
recontactDays:
type: integer
example: 7
clickOutsideClose:
type: boolean
example: true
darkOverlay:
type: boolean
example: false
placement:
type: string
example: "bottomRight"
inAppSurveyBranding:
type: boolean
example: true
styling:
type: object
properties:
brandColor:
type: object
properties:
light:
type: string
example: "#64748b"
allowStyleOverwrite:
type: boolean
example: true
"404":
description: Not Found
content:
application/json:
schema:
type: object
properties:
code:
type: string
example: "not_found"
message:
type: string
example: "Environment not found"
details:
type: object
properties:
resource_id:
type: string
example: "tpywklouw2p7tebdu4zv01an"
resource_type:
type: string
example: "environment"
"500":
description: Internal Server Error
content:
application/json:
schema:
type: object
properties:
code:
type: string
example: "internal_server_error"
message:
type: string
example: "Unable to complete request: Expected property name or '}' in JSON at position 29"
details:
type: object
/api/v1/client/{environmentId}/identify/contacts/{userId}:
get:
tags:
- Client API > Contacts
summary: Get Contact State
description: >-
Retrieves a contact's state including their segments, displays, responses
and other tracking information. If the contact doesn't exist, it will be created.
parameters:
- name: environmentId
in: path
required: true
schema:
type: string
description: The ID of the environment
- name: userId
in: path
required: true
schema:
type: string
description: The user ID to identify the contact
responses:
"200":
description: OK
headers:
Access-Control-Allow-Credentials:
schema:
type: boolean
example: "true"
Access-Control-Allow-Origin:
schema:
type: string
example: "*"
access-control-allow-methods:
schema:
type: string
example: GET, POST, PUT, DELETE, OPTIONS
access-control-allow-headers:
schema:
type: string
example: Content-Type, Authorization
vary:
schema:
type: string
example: RSC, Next-Router-State-Tree, Next-Router-Prefetch
cache-control:
schema:
type: string
example: private, no-store
content-type:
schema:
type: string
example: application/json
Date:
schema:
type: string
example: Tue, 23 Apr 2024 07:35:47 GMT
Connection:
schema:
type: string
example: keep-alive
Keep-Alive:
schema:
type: string
example: timeout=5
Transfer-Encoding:
schema:
type: string
example: chunked
content:
application/json:
schema:
type: object
example:
data:
userId: Shubham
"400":
description: Bad Request
headers:
Access-Control-Allow-Credentials:
schema:
type: boolean
example: "true"
Access-Control-Allow-Origin:
schema:
type: string
example: "*"
access-control-allow-methods:
schema:
type: string
example: GET, POST, PUT, DELETE, OPTIONS
access-control-allow-headers:
schema:
type: string
example: Content-Type, Authorization
vary:
schema:
type: string
example: RSC, Next-Router-State-Tree, Next-Router-Prefetch
content-type:
schema:
type: string
example: application/json
Date:
schema:
type: string
example: Tue, 23 Apr 2024 07:36:43 GMT
Connection:
schema:
type: string
example: keep-alive
Keep-Alive:
schema:
type: string
example: timeout=5
Transfer-Encoding:
schema:
type: string
example: chunked
properties:
data:
type: object
properties:
userId:
type: string
description: The user ID of the contact
segments:
type: array
description: List of segment IDs the contact belongs to
items:
type: string
displays:
type: array
description: List of survey displays for this contact
items:
type: object
properties:
surveyId:
type: string
createdAt:
type: string
format: date-time
responses:
type: array
description: List of survey IDs the contact has responded to
items:
type: string
lastDisplayAt:
type: string
format: date-time
nullable: true
description: Timestamp of the last survey display
example:
userId: "user-123"
segments: ["fi8f9oekza95wwszrptidivq", "zgwrv8eg7vfavdhzv1s0po1w"]
displays: [{ surveyId: "pjogp5a1wyxon6umplmf49b8", createdAt: "2024-04-23T08:59:37.550Z" }]
responses: ["pjogp5a1wyxon6umplmf49b8"]
lastDisplayAt: "2024-04-23T08:59:37.550Z"
"403":
description: Forbidden
content:
application/json:
schema:
type: object
example:
code: bad_request
message: userId is required
details:
environmentId: clurwouax000azffxt7n5unn3
/api/v1/client/{environmentId}/people/{userId}/attributes:
properties:
code:
type: string
example: "forbidden"
message:
type: string
example: "User identification is only available for enterprise users."
"404":
description: Not Found
content:
application/json:
schema:
type: object
properties:
code:
type: string
example: "not_found"
message:
type: string
example: "Environment not found"
details:
type: object
properties:
resource_id:
type: string
example: "tpywklouw2p7tebdu4zv01an"
resource_type:
type: string
example: "environment"
"500":
description: Internal Server Error
content:
application/json:
schema:
type: object
properties:
code:
type: string
example: "internal_server_error"
message:
type: string
example: "Unable to complete request: Expected property name or '}' in JSON at position 29"
details:
type: object
/api/v1/client/{environmentId}/contacts/{userId}/attributes:
put:
tags:
- Client API > People
summary: Update Person
- Client API > Contacts
summary: Update Contact (Attributes)
description: >-
Update a person's attributes in Formbricks to keep them in sync with
Update a contact's attributes in Formbricks to keep them in sync with
your app or when you want to set a custom attribute in Formbricks.
requestBody:
content:
@@ -713,6 +1006,280 @@ paths:
Unable to complete request: Expected property name or '}' in
JSON at position 29
details: {}
/api/v1/client/{environmentId}/user:
post:
tags:
- Client API > User
summary: Create or Identify User
description: >
Endpoint for creating or identifying a user within the specified environment.
If the user already exists, this will identify them and potentially
update user attributes. If they don't exist, it will create a new user.
requestBody:
content:
application/json:
schema:
type: object
example:
userId: "hello-user"
attributes:
plan: "free"
parameters:
- name: environmentId
in: path
required: true
schema:
type: string
responses:
"200":
description: OK
headers:
Access-Control-Allow-Credentials:
schema:
type: boolean
example: "true"
Access-Control-Allow-Origin:
schema:
type: string
example: "*"
access-control-allow-methods:
schema:
type: string
example: GET, POST, PUT, DELETE, OPTIONS
access-control-allow-headers:
schema:
type: string
example: Content-Type, Authorization
vary:
schema:
type: string
example: RSC, Next-Router-State-Tree, Next-Router-Prefetch
content-type:
schema:
type: string
example: application/json
Date:
schema:
type: string
example: Tue, 23 Apr 2024 07:36:43 GMT
Connection:
schema:
type: string
example: keep-alive
Keep-Alive:
schema:
type: string
example: timeout=5
Transfer-Encoding:
schema:
type: string
example: chunked
content:
application/json:
schema:
type: object
properties:
data:
type: object
properties:
state:
type: object
properties:
data:
type: object
properties:
userId:
type: string
example: "hello-user"
segments:
type: array
items:
type: string
example: "cm6onrezn000hw2ahcokiz41v"
displays:
type: array
items:
type: object
properties:
surveyId:
type: string
example: "cm6orqtdd000a19wjhnbces5s"
createdAt:
type: string
example: "2025-02-03T11:23:13.050Z"
responses:
type: array
items:
type: string
example: "cm6orqtdd000a19wjhnbces5s"
lastDisplayAt:
type: string
example: "2025-02-03T11:23:13.050Z"
expiresAt:
type: string
example: "2025-02-03T11:23:13.050Z"
"400":
description: Bad Request
headers:
Access-Control-Allow-Credentials:
schema:
type: boolean
example: "true"
Access-Control-Allow-Origin:
schema:
type: string
example: "*"
access-control-allow-methods:
schema:
type: string
example: GET, POST, PUT, DELETE, OPTIONS
access-control-allow-headers:
schema:
type: string
example: Content-Type, Authorization
vary:
schema:
type: string
example: RSC, Next-Router-State-Tree, Next-Router-Prefetch
content-type:
schema:
type: string
example: application/json
Date:
schema:
type: string
example: Tue, 23 Apr 2024 07:36:43 GMT
Connection:
schema:
type: string
example: keep-alive
Keep-Alive:
schema:
type: string
example: timeout=5
Transfer-Encoding:
schema:
type: string
example: chunked
content:
application/json:
schema:
type: object
example:
code: bad_request
message: userId is required
"404":
description: Not Found
headers:
Access-Control-Allow-Credentials:
schema:
type: boolean
example: "true"
Access-Control-Allow-Origin:
schema:
type: string
example: "*"
access-control-allow-methods:
schema:
type: string
example: GET, POST, PUT, DELETE, OPTIONS
access-control-allow-headers:
schema:
type: string
example: Content-Type, Authorization
vary:
schema:
type: string
example: RSC, Next-Router-State-Tree, Next-Router-Prefetch
cache-control:
schema:
type: string
example: private, no-store
content-type:
schema:
type: string
example: application/json
Date:
schema:
type: string
example: Tue, 23 Apr 2024 07:56:29 GMT
Connection:
schema:
type: string
example: keep-alive
Keep-Alive:
schema:
type: string
example: timeout=5
Transfer-Encoding:
schema:
type: string
example: chunked
content:
application/json:
schema:
type: object
example:
code: not_found
message: Environment not found
details:
resource_id: f16ttdvtkx85k5m4s561ruqj
resource_type: Environment
"500":
description: Internal Server Error
headers:
Access-Control-Allow-Credentials:
schema:
type: boolean
example: "true"
Access-Control-Allow-Origin:
schema:
type: string
example: "*"
access-control-allow-methods:
schema:
type: string
example: GET, POST, PUT, DELETE, OPTIONS
access-control-allow-headers:
schema:
type: string
example: Content-Type, Authorization
vary:
schema:
type: string
example: RSC, Next-Router-State-Tree, Next-Router-Prefetch
cache-control:
schema:
type: string
example: private, no-store
content-type:
schema:
type: string
example: application/json
Date:
schema:
type: string
example: Tue, 23 Apr 2024 07:56:29 GMT
Connection:
schema:
type: string
example: keep-alive
Keep-Alive:
schema:
type: string
example: timeout=5
Transfer-Encoding:
schema:
type: string
example: chunked
content:
application/json:
schema:
type: object
example:
code: internal_server_error
message: An unexpected error occurred
details: {}
/api/v1/client/{environmentId}/responses:
post:
tags:

View File

@@ -1,4 +1,4 @@
FROM node:22-alpine3.20 AS base
FROM node:22-alpine AS base
#
## step 1: Prune monorepo
@@ -18,7 +18,7 @@ FROM node:22-alpine3.20 AS base
FROM base AS installer
# Enable corepack and prepare pnpm
RUN corepack enable
RUN npm install -g pnpm@9.15.0
# Install necessary build tools and compilers
RUN apk update && apk add --no-cache g++ cmake make gcc python3 openssl-dev jq

View File

@@ -127,7 +127,7 @@ export const LandingSidebar = ({
await signOut({ callbackUrl: "/auth/login" });
await formbricksLogout();
}}
icon={<LogOutIcon className="h-4 w-4" strokeWidth={1.5} />}>
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
{t("common.logout")}
</DropdownMenuItem>

View File

@@ -392,7 +392,7 @@ export const MainNavigation = ({
router.push(route.url);
await formbricksLogout();
}}
icon={<LogOutIcon className="h-4 w-4" strokeWidth={1.5} />}>
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
{t("common.logout")}
</DropdownMenuItem>

View File

@@ -1,3 +1,4 @@
import { ProjectSettingsLayout } from "@/modules/projects/settings/layout";
import { ProjectSettingsLayout, metadata } from "@/modules/projects/settings/layout";
export { metadata };
export default ProjectSettingsLayout;

View File

@@ -43,7 +43,7 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
}
const rawEmailHtml = await getEmailTemplateHtml(parsedInput.surveyId);
const rawEmailHtml = await getEmailTemplateHtml(parsedInput.surveyId, ctx.user.locale);
const emailHtml = rawEmailHtml
.replaceAll("?preview=true&amp;", "?")
.replaceAll("?preview=true&;", "?")
@@ -51,7 +51,6 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
return await sendEmbedSurveyPreviewEmail(
ctx.user.email,
"Formbricks Email Survey Preview",
emailHtml,
survey.environmentId,
ctx.user.locale,
@@ -182,5 +181,5 @@ export const getEmailHtmlAction = authenticatedActionClient
],
});
return await getEmailTemplateHtml(parsedInput.surveyId);
return await getEmailTemplateHtml(parsedInput.surveyId, ctx.user.locale);
});

View File

@@ -77,12 +77,7 @@ export const FileUploadSummary = ({
return (
<div className="relative m-2 rounded-lg bg-slate-200" key={fileUrl}>
<a
href={fileUrl as string}
key={index}
download={fileName}
target="_blank"
rel="noopener noreferrer">
<a href={fileUrl} key={index} target="_blank" rel="noopener noreferrer">
<div className="absolute right-0 top-0 m-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-50 hover:bg-white">
<DownloadIcon className="h-6 text-slate-500" />

View File

@@ -30,7 +30,7 @@ export const PanelInfoView = ({ disableBack, handleInitialPageButton }: PanelInf
<Image src={ProlificUI} alt="Prolific panel selection UI" className="rounded-lg shadow-lg" />
<div>
<p className="text-md font-semibold">{t("environments.surveys.summary.what_is_a_panel")}</p>
<p className="text-slate-600">{t("environments.surveys.summary.what_is_a_pannel_answer")}</p>
<p className="text-slate-600">{t("environments.surveys.summary.what_is_a_panel_answer")}</p>
</div>
<div>
<p className="text-md font-semibold">{t("environments.surveys.summary.when_do_i_need_it")}</p>

View File

@@ -4,7 +4,7 @@ import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { getSurvey } from "@formbricks/lib/survey/service";
import { getStyling } from "@formbricks/lib/utils/styling";
export const getEmailTemplateHtml = async (surveyId: string) => {
export const getEmailTemplateHtml = async (surveyId: string, locale: string) => {
const survey = await getSurvey(surveyId);
if (!survey) {
throw new Error("Survey not found");
@@ -16,7 +16,7 @@ export const getEmailTemplateHtml = async (surveyId: string) => {
const styling = getStyling(project, survey);
const surveyUrl = WEBAPP_URL + "/s/" + survey.id;
const html = await getPreviewEmailTemplateHtml(survey, surveyUrl, styling);
const html = await getPreviewEmailTemplateHtml(survey, surveyUrl, styling, locale);
const doctype =
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';
const htmlCleaned = html.toString().replace(doctype, "");

View File

@@ -1,3 +1,4 @@
import { LoginPage } from "@/modules/auth/login/page";
import { LoginPage, metadata } from "@/modules/auth/login/page";
export { metadata };
export default LoginPage;

View File

@@ -1,133 +1,3 @@
import { authOptions } from "@/modules/auth/lib/authOptions";
import { sendInviteAcceptedEmail } from "@/modules/email";
import { createTeamMembership } from "@/modules/invite/lib/team";
import { Button } from "@/modules/ui/components/button";
import { getServerSession } from "next-auth";
import { getTranslations } from "next-intl/server";
import Link from "next/link";
import { after } from "next/server";
import { DEFAULT_LOCALE, WEBAPP_URL } from "@formbricks/lib/constants";
import { deleteInvite, getInvite } from "@formbricks/lib/invite/service";
import { verifyInviteToken } from "@formbricks/lib/jwt";
import { createMembership } from "@formbricks/lib/membership/service";
import { getUser, updateUser } from "@formbricks/lib/user/service";
import { ContentLayout } from "./components/ContentLayout";
import { InvitePage } from "@/modules/auth/invite/page";
const Page = async (props) => {
const searchParams = await props.searchParams;
const t = await getTranslations();
const session = await getServerSession(authOptions);
const user = session?.user.id ? await getUser(session.user.id) : null;
try {
const { inviteId, email } = verifyInviteToken(searchParams.token);
const invite = await getInvite(inviteId);
if (!invite) {
return (
<ContentLayout
headline={t("auth.invite.invite_not_found")}
description={t("auth.invite.invite_not_found_description")}
/>
);
}
const isInviteExpired = new Date(invite.expiresAt) < new Date();
const createMembershipAction = async () => {
"use server";
if (!session || !user) return;
await createMembership(invite.organizationId, session.user.id, {
accepted: true,
role: invite.role,
});
if (invite.teamIds) {
await createTeamMembership(invite, user.id);
}
await deleteInvite(inviteId);
await sendInviteAcceptedEmail(
invite.creator.name ?? "",
user?.name ?? "",
invite.creator.email,
user?.locale ?? DEFAULT_LOCALE
);
await updateUser(session.user.id, {
notificationSettings: {
...user.notificationSettings,
alert: user.notificationSettings.alert ?? {},
weeklySummary: user.notificationSettings.weeklySummary ?? {},
unsubscribedOrganizationIds: Array.from(
new Set([
...(user.notificationSettings?.unsubscribedOrganizationIds || []),
invite.organizationId,
])
),
},
});
};
if (isInviteExpired) {
return (
<ContentLayout
headline={t("auth.invite.invite_expired")}
description={t("auth.invite.invite_expired_description")}
/>
);
} else if (!session) {
const redirectUrl = WEBAPP_URL + "/invite?token=" + searchParams.token;
const encodedEmail = encodeURIComponent(email);
return (
<ContentLayout
headline={t("auth.invite.happy_to_have_you")}
description={t("auth.invite.happy_to_have_you_description")}>
<Button variant="secondary" asChild>
<Link href={`/auth/signup?inviteToken=${searchParams.token}&email=${encodedEmail}`}>
{t("auth.invite.create_account")}
</Link>
</Button>
<Button asChild>
<Link href={`/auth/login?callbackUrl=${redirectUrl}&email=${encodedEmail}`}>
{t("auth.invite.login")}
</Link>
</Button>
</ContentLayout>
);
} else if (user?.email?.toLowerCase() !== email?.toLowerCase()) {
return (
<ContentLayout
headline={t("auth.invite.email_does_not_match")}
description={t("auth.invite.email_does_not_match_description")}>
<Button asChild>
<Link href="/">{t("auth.invite.go_to_app")}</Link>
</Button>
</ContentLayout>
);
} else {
after(async () => {
await createMembershipAction();
});
return (
<ContentLayout
headline={t("auth.invite.welcome_to_organization")}
description={t("auth.invite.welcome_to_organization_description")}>
<Button asChild>
<Link href="/">{t("auth.invite.go_to_app")}</Link>
</Button>
</ContentLayout>
);
}
} catch (e) {
console.error(e);
return (
<ContentLayout
headline={t("auth.invite.invite_not_found")}
description={t("auth.invite.invite_not_found_description")}
/>
);
}
};
export default Page;
export default InvitePage;

View File

@@ -29,7 +29,7 @@ import { getSurveysForEnvironmentState } from "./survey";
*/
export const getEnvironmentState = async (
environmentId: string
): Promise<{ state: TJsEnvironmentState["data"]; revalidateEnvironment?: boolean }> =>
): Promise<{ data: TJsEnvironmentState["data"]; revalidateEnvironment?: boolean }> =>
cache(
async () => {
let revalidateEnvironment = false;
@@ -102,14 +102,14 @@ export const getEnvironmentState = async (
(survey) => survey.type === "app" && survey.status === "inProgress"
);
const state: TJsEnvironmentState["data"] = {
const data: TJsEnvironmentState["data"] = {
surveys: !isMonthlyResponsesLimitReached ? filteredSurveys : [],
actionClasses,
project: project,
};
return {
state,
data,
revalidateEnvironment,
};
},

View File

@@ -36,16 +36,20 @@ export const GET = async (
try {
const environmentState = await getEnvironmentState(params.environmentId);
const { data, revalidateEnvironment } = environmentState;
if (environmentState.revalidateEnvironment) {
if (revalidateEnvironment) {
environmentCache.revalidate({
id: inputValidation.data.environmentId,
projectId: environmentState.state.project.id,
projectId: data.project.id,
});
}
return responses.successResponse(
environmentState.state,
{
data,
expiresAt: new Date(Date.now() + 1000 * 60 * 30), // 30 minutes
},
true,
"public, s-maxage=600, max-age=840, stale-while-revalidate=600, stale-if-error=600"
);

View File

@@ -0,0 +1,3 @@
import { OPTIONS, POST } from "@/modules/ee/contacts/api/client/[environmentId]/user/route";
export { POST, OPTIONS };

View File

@@ -1,35 +1,4 @@
import { Button } from "@/modules/ui/components/button";
import { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import Link from "next/link";
import { IntroPage, metadata } from "@/modules/setup/(fresh-instance)/intro/page";
export const metadata: Metadata = {
title: "Intro",
description: "Open-source Experience Management. Free & open source.",
};
const renderRichText = async (text: string) => {
const t = await getTranslations();
return <p>{t.rich(text, { b: (chunks) => <b>{chunks}</b> })}</p>;
};
const Page = async () => {
const t = await getTranslations();
return (
<div className="flex flex-col items-center">
<h2 className="mb-6 text-xl font-medium">{t("setup.intro.welcome_to_formbricks")}</h2>
<div className="mx-auto max-w-sm space-y-4 text-sm leading-6 text-slate-600">
{renderRichText("setup.intro.paragraph_1")}
{renderRichText("setup.intro.paragraph_2")}
{renderRichText("setup.intro.paragraph_3")}
</div>
<Button className="mt-6" asChild>
<Link href="/setup/signup">{t("setup.intro.get_started")}</Link>
</Button>
<p className="pt-6 text-xs text-slate-400">{t("setup.intro.made_with_love_in_kiel")}</p>
</div>
);
};
export default Page;
export { metadata };
export default IntroPage;

View File

@@ -1,16 +1,3 @@
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getServerSession } from "next-auth";
import { notFound } from "next/navigation";
import { getIsFreshInstance } from "@formbricks/lib/instance/service";
const FreshInstanceLayout = async ({ children }: { children: React.ReactNode }) => {
const session = await getServerSession(authOptions);
const isFreshInstance = await getIsFreshInstance();
if (session ?? !isFreshInstance) {
return notFound();
}
return <>{children}</>;
};
import { FreshInstanceLayout } from "@/modules/setup/(fresh-instance)/layout";
export default FreshInstanceLayout;

View File

@@ -1,57 +1,4 @@
import { SignupForm } from "@/modules/auth/signup/components/signup-form";
import { getIsSSOEnabled } from "@/modules/ee/license-check/lib/utils";
import { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import {
AZURE_OAUTH_ENABLED,
DEFAULT_ORGANIZATION_ID,
DEFAULT_ORGANIZATION_ROLE,
EMAIL_AUTH_ENABLED,
EMAIL_VERIFICATION_DISABLED,
GITHUB_OAUTH_ENABLED,
GOOGLE_OAUTH_ENABLED,
IS_TURNSTILE_CONFIGURED,
OIDC_DISPLAY_NAME,
OIDC_OAUTH_ENABLED,
PRIVACY_URL,
TERMS_URL,
WEBAPP_URL,
} from "@formbricks/lib/constants";
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
import { SignupPage, metadata } from "@/modules/setup/(fresh-instance)/signup/page";
export const metadata: Metadata = {
title: "Sign up",
description: "Open-source Experience Management. Free & open source.",
};
const Page = async () => {
const locale = await findMatchingLocale();
const isSSOEnabled = await getIsSSOEnabled();
const t = await getTranslations();
return (
<div className="flex flex-col items-center">
<h2 className="mb-6 text-xl font-medium">{t("setup.signup.create_administrator")}</h2>
<p className="text-sm text-slate-800">{t("setup.signup.this_user_has_all_the_power")}</p>
<hr className="my-6 w-full border-slate-200" />
<SignupForm
webAppUrl={WEBAPP_URL}
termsUrl={TERMS_URL}
privacyUrl={PRIVACY_URL}
emailVerificationDisabled={EMAIL_VERIFICATION_DISABLED}
emailAuthEnabled={EMAIL_AUTH_ENABLED}
googleOAuthEnabled={GOOGLE_OAUTH_ENABLED}
githubOAuthEnabled={GITHUB_OAUTH_ENABLED}
azureOAuthEnabled={AZURE_OAUTH_ENABLED}
oidcOAuthEnabled={OIDC_OAUTH_ENABLED}
oidcDisplayName={OIDC_DISPLAY_NAME}
userLocale={locale}
defaultOrganizationId={DEFAULT_ORGANIZATION_ID}
defaultOrganizationRole={DEFAULT_ORGANIZATION_ROLE}
isSSOEnabled={isSSOEnabled}
isTurnstileConfigured={IS_TURNSTILE_CONFIGURED}
/>
</div>
);
};
export default Page;
export { metadata };
export default SignupPage;

View File

@@ -1,22 +1,3 @@
import { FormbricksLogo } from "@/modules/ui/components/formbricks-logo";
import { Toaster } from "react-hot-toast";
const SetupLayout = ({ children }: { children: React.ReactNode }) => {
return (
<>
<Toaster />
<div className="flex h-full w-full items-center justify-center bg-slate-50">
<div
style={{ scrollbarGutter: "stable both-edges" }}
className="flex max-h-[90vh] w-[40rem] flex-col items-center space-y-4 overflow-auto rounded-lg border bg-white p-12 text-center shadow-md">
<div className="h-20 w-20 rounded-lg bg-slate-900 p-2">
<FormbricksLogo className="h-full w-full" />
</div>
{children}
</div>
</div>
</>
);
};
import { SetupLayout } from "@/modules/setup/layout";
export default SetupLayout;

View File

@@ -1,40 +1,4 @@
import { InviteMembers } from "@/app/setup/organization/[organizationId]/invite/components/invite-members";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { Metadata } from "next";
import { getServerSession } from "next-auth";
import { getTranslations } from "next-intl/server";
import { notFound } from "next/navigation";
import { SMTP_HOST, SMTP_PASSWORD, SMTP_PORT, SMTP_USER } from "@formbricks/lib/constants";
import { verifyUserRoleAccess } from "@formbricks/lib/organization/auth";
import { AuthenticationError } from "@formbricks/types/errors";
import { InvitePage, metadata } from "@/modules/setup/organization/[organizationId]/invite/page";
type Params = Promise<{
organizationId: string;
}>;
export const metadata: Metadata = {
title: "Invite",
description: "Open-source Experience Management. Free & open source.",
};
interface InvitePageProps {
params: Params;
}
const Page = async (props: InvitePageProps) => {
const params = await props.params;
const t = await getTranslations();
const IS_SMTP_CONFIGURED = Boolean(SMTP_HOST && SMTP_PORT && SMTP_USER && SMTP_PASSWORD);
const session = await getServerSession(authOptions);
if (!session) throw new AuthenticationError(t("common.session_not_found"));
const { hasCreateOrUpdateMembersAccess } = await verifyUserRoleAccess(
params.organizationId,
session.user.id
);
if (!hasCreateOrUpdateMembersAccess) return notFound();
return <InviteMembers IS_SMTP_CONFIGURED={IS_SMTP_CONFIGURED} organizationId={params.organizationId} />;
};
export default Page;
export { metadata };
export default InvitePage;

View File

@@ -1,47 +1,4 @@
import { RemovedFromOrganization } from "@/app/setup/organization/create/components/removed-from-organization";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { ClientLogout } from "@/modules/ui/components/client-logout";
import { Metadata } from "next";
import { getServerSession } from "next-auth";
import { getTranslations } from "next-intl/server";
import { notFound } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { gethasNoOrganizations } from "@formbricks/lib/instance/service";
import { getOrganizationsByUserId } from "@formbricks/lib/organization/service";
import { getUser } from "@formbricks/lib/user/service";
import { AuthenticationError } from "@formbricks/types/errors";
import { CreateOrganization } from "./components/create-organization";
import { CreateOrganizationPage, metadata } from "@/modules/setup/organization/create/page";
export const metadata: Metadata = {
title: "Create Organization",
description: "Open-source Experience Management. Free & open source.",
};
const Page = async () => {
const t = await getTranslations();
const session = await getServerSession(authOptions);
if (!session) throw new AuthenticationError(t("common.session_not_found"));
const user = await getUser(session.user.id);
if (!user) {
return <ClientLogout />;
}
const hasNoOrganizations = await gethasNoOrganizations();
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const userOrganizations = await getOrganizationsByUserId(session.user.id);
if (hasNoOrganizations || isMultiOrgEnabled) {
return <CreateOrganization />;
}
if (userOrganizations.length === 0) {
return <RemovedFromOrganization user={user} isFormbricksCloud={IS_FORMBRICKS_CLOUD} />;
}
return notFound();
};
export default Page;
export { metadata };
export default CreateOrganizationPage;

View File

@@ -2,6 +2,7 @@
import { apiKeyCache } from "@/lib/cache/api-key";
import { contactCache } from "@/lib/cache/contact";
import { inviteCache } from "@/lib/cache/invite";
import { teamCache } from "@/lib/cache/team";
import { webhookCache } from "@/lib/cache/webhook";
import { Prisma } from "@prisma/client";
@@ -12,7 +13,6 @@ import { cache } from "@formbricks/lib/cache";
import { segmentCache } from "@formbricks/lib/cache/segment";
import { environmentCache } from "@formbricks/lib/environment/cache";
import { integrationCache } from "@formbricks/lib/integration/cache";
import { inviteCache } from "@formbricks/lib/invite/cache";
import { projectCache } from "@formbricks/lib/project/cache";
import { responseCache } from "@formbricks/lib/response/cache";
import { responseNoteCache } from "@formbricks/lib/responseNote/cache";

View File

@@ -0,0 +1,78 @@
import { inviteCache } from "@/lib/cache/invite";
import { type InviteWithCreator } from "@/modules/auth/invite/types/invites";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
export const deleteInvite = async (inviteId: string): Promise<boolean> => {
try {
const invite = await prisma.invite.delete({
where: {
id: inviteId,
},
select: {
id: true,
organizationId: true,
},
});
if (!invite) {
throw new ResourceNotFoundError("Invite", inviteId);
}
inviteCache.revalidate({
id: invite.id,
organizationId: invite.organizationId,
});
return true;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const getInvite = reactCache(
async (inviteId: string): Promise<InviteWithCreator | null> =>
cache(
async () => {
try {
const invite = await prisma.invite.findUnique({
where: {
id: inviteId,
},
select: {
id: true,
expiresAt: true,
organizationId: true,
role: true,
teamIds: true,
creator: {
select: {
name: true,
email: true,
},
},
},
});
return invite;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`invite-getInvite-${inviteId}`],
{
tags: [inviteCache.tag.byId(inviteId)],
}
)()
);

View File

@@ -1,17 +1,13 @@
import "server-only";
import { teamCache } from "@/lib/cache/team";
import { CreateMembershipInvite } from "@/modules/auth/invite/types/invites";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { projectCache } from "@formbricks/lib/project/cache";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { ZString } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
import { TInvite, ZInvite } from "@formbricks/types/invites";
export const createTeamMembership = async (invite: TInvite, userId: string): Promise<void> => {
validateInputs([invite, ZInvite], [userId, ZString]);
export const createTeamMembership = async (invite: CreateMembershipInvite, userId: string): Promise<void> => {
const teamIds = invite.teamIds || [];
const userMembershipRole = invite.role;
const { isOwner, isManager } = getAccessFlags(userMembershipRole);

View File

@@ -0,0 +1,147 @@
import { deleteInvite, getInvite } from "@/modules/auth/invite/lib/invite";
import { createTeamMembership } from "@/modules/auth/invite/lib/team";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { sendInviteAcceptedEmail } from "@/modules/email";
import { Button } from "@/modules/ui/components/button";
import { getServerSession } from "next-auth";
import { getTranslations } from "next-intl/server";
import Link from "next/link";
import { after } from "next/server";
import { DEFAULT_LOCALE, WEBAPP_URL } from "@formbricks/lib/constants";
import { verifyInviteToken } from "@formbricks/lib/jwt";
import { createMembership } from "@formbricks/lib/membership/service";
import { getUser, updateUser } from "@formbricks/lib/user/service";
import { ContentLayout } from "./components/content-layout";
interface InvitePageProps {
searchParams: Promise<{ token: string }>;
}
export const InvitePage = async (props: InvitePageProps) => {
const searchParams = await props.searchParams;
const t = await getTranslations();
const session = await getServerSession(authOptions);
const user = session?.user.id ? await getUser(session.user.id) : null;
try {
const { inviteId, email } = verifyInviteToken(searchParams.token);
const invite = await getInvite(inviteId);
if (!invite) {
return (
<ContentLayout
headline={t("auth.invite.invite_not_found")}
description={t("auth.invite.invite_not_found_description")}
/>
);
}
const isInviteExpired = new Date(invite.expiresAt) < new Date();
if (isInviteExpired) {
return (
<ContentLayout
headline={t("auth.invite.invite_expired")}
description={t("auth.invite.invite_expired_description")}
/>
);
}
if (!session) {
const redirectUrl = WEBAPP_URL + "/invite?token=" + searchParams.token;
const encodedEmail = encodeURIComponent(email);
return (
<ContentLayout
headline={t("auth.invite.happy_to_have_you")}
description={t("auth.invite.happy_to_have_you_description")}>
<Button variant="secondary" asChild>
<Link href={`/auth/signup?inviteToken=${searchParams.token}&email=${encodedEmail}`}>
{t("auth.invite.create_account")}
</Link>
</Button>
<Button asChild>
<Link href={`/auth/login?callbackUrl=${redirectUrl}&email=${encodedEmail}`}>
{t("auth.invite.login")}
</Link>
</Button>
</ContentLayout>
);
}
if (user?.email?.toLowerCase() !== email?.toLowerCase()) {
return (
<ContentLayout
headline={t("auth.invite.email_does_not_match")}
description={t("auth.invite.email_does_not_match_description")}>
<Button asChild>
<Link href="/">{t("auth.invite.go_to_app")}</Link>
</Button>
</ContentLayout>
);
}
const createMembershipAction = async () => {
"use server";
if (!session || !user) return;
await createMembership(invite.organizationId, session.user.id, {
accepted: true,
role: invite.role,
});
if (invite.teamIds) {
await createTeamMembership(
{
organizationId: invite.organizationId,
role: invite.role,
teamIds: invite.teamIds,
},
user.id
);
}
await deleteInvite(inviteId);
await sendInviteAcceptedEmail(
invite.creator.name ?? "",
user?.name ?? "",
invite.creator.email,
user?.locale ?? DEFAULT_LOCALE
);
await updateUser(session.user.id, {
notificationSettings: {
...user.notificationSettings,
alert: user.notificationSettings.alert ?? {},
weeklySummary: user.notificationSettings.weeklySummary ?? {},
unsubscribedOrganizationIds: Array.from(
new Set([
...(user.notificationSettings?.unsubscribedOrganizationIds || []),
invite.organizationId,
])
),
},
});
};
after(async () => {
await createMembershipAction();
});
return (
<ContentLayout
headline={t("auth.invite.welcome_to_organization")}
description={t("auth.invite.welcome_to_organization_description")}>
<Button asChild>
<Link href="/">{t("auth.invite.go_to_app")}</Link>
</Button>
</ContentLayout>
);
} catch (e) {
console.error(e);
return (
<ContentLayout
headline={t("auth.invite.invite_not_found")}
description={t("auth.invite.invite_not_found_description")}
/>
);
}
};

View File

@@ -0,0 +1,11 @@
import { Invite } from "@prisma/client";
export interface InviteWithCreator
extends Pick<Invite, "id" | "expiresAt" | "organizationId" | "role" | "teamIds"> {
creator: {
name: string | null;
email: string;
};
}
export interface CreateMembershipInvite extends Pick<Invite, "organizationId" | "role" | "teamIds"> {}

View File

@@ -1,17 +1,15 @@
"use server";
import { actionClient } from "@/lib/utils/action-client";
import { createUser } from "@/modules/auth/lib/user";
import { updateUser } from "@/modules/auth/lib/user";
import { createUser, updateUser } from "@/modules/auth/lib/user";
import { deleteInvite, getInvite } from "@/modules/auth/signup/lib/invite";
import { createTeamMembership } from "@/modules/auth/signup/lib/team";
import { captureFailedSignup, verifyTurnstileToken } from "@/modules/auth/signup/lib/utils";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { sendInviteAcceptedEmail, sendVerificationEmail } from "@/modules/email";
import { createTeamMembership } from "@/modules/invite/lib/team";
import { z } from "zod";
import { hashPassword } from "@formbricks/lib/auth";
import { IS_TURNSTILE_CONFIGURED, TURNSTILE_SECRET_KEY } from "@formbricks/lib/constants";
import { getInvite } from "@formbricks/lib/invite/service";
import { deleteInvite } from "@formbricks/lib/invite/service";
import { verifyInviteToken } from "@formbricks/lib/jwt";
import { createMembership } from "@formbricks/lib/membership/service";
import { createOrganization, getOrganization } from "@formbricks/lib/organization/service";
@@ -74,7 +72,14 @@ export const createUserAction = actionClient.schema(ZCreateUserAction).action(as
});
if (invite.teamIds) {
await createTeamMembership(invite, user.id);
await createTeamMembership(
{
organizationId: invite.organizationId,
role: invite.role,
teamIds: invite.teamIds,
},
user.id
);
}
await updateUser(user.id, {
@@ -85,7 +90,12 @@ export const createUserAction = actionClient.schema(ZCreateUserAction).action(as
},
});
await sendInviteAcceptedEmail(invite.creator.name ?? "", user.name, invite.creator.email, user.locale);
await sendInviteAcceptedEmail(
invite.creator.name ?? "",
user.name,
invite.creator.email,
invite.creator.locale
);
await deleteInvite(invite.id);
}
// Handle organization assignment

View File

@@ -0,0 +1,78 @@
import { inviteCache } from "@/lib/cache/invite";
import { InviteWithCreator } from "@/modules/auth/signup/types/invites";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
export const deleteInvite = async (inviteId: string): Promise<boolean> => {
try {
const invite = await prisma.invite.delete({
where: {
id: inviteId,
},
select: {
id: true,
organizationId: true,
},
});
if (!invite) {
throw new ResourceNotFoundError("Invite", inviteId);
}
inviteCache.revalidate({
id: invite.id,
organizationId: invite.organizationId,
});
return true;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const getInvite = reactCache(
async (inviteId: string): Promise<InviteWithCreator | null> =>
cache(
async () => {
try {
const invite = await prisma.invite.findUnique({
where: {
id: inviteId,
},
select: {
id: true,
organizationId: true,
role: true,
teamIds: true,
creator: {
select: {
name: true,
email: true,
locale: true,
},
},
},
});
return invite;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`signup-getInvite-${inviteId}`],
{
tags: [inviteCache.tag.byId(inviteId)],
}
)()
);

View File

@@ -0,0 +1,65 @@
import "server-only";
import { teamCache } from "@/lib/cache/team";
import { CreateMembershipInvite } from "@/modules/auth/signup/types/invites";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { projectCache } from "@formbricks/lib/project/cache";
import { DatabaseError } from "@formbricks/types/errors";
export const createTeamMembership = async (invite: CreateMembershipInvite, userId: string): Promise<void> => {
const teamIds = invite.teamIds || [];
const userMembershipRole = invite.role;
const { isOwner, isManager } = getAccessFlags(userMembershipRole);
const validTeamIds: string[] = [];
const validProjectIds: string[] = [];
const isOwnerOrManager = isOwner || isManager;
try {
for (const teamId of teamIds) {
const team = await prisma.team.findUnique({
where: {
id: teamId,
},
select: {
projectTeams: {
select: {
projectId: true,
},
},
},
});
if (team) {
await prisma.teamUser.create({
data: {
teamId,
userId,
role: isOwnerOrManager ? "admin" : "contributor",
},
});
validTeamIds.push(teamId);
validProjectIds.push(...team.projectTeams.map((pt) => pt.projectId));
}
}
for (const projectId of validProjectIds) {
teamCache.revalidate({ id: projectId });
}
for (const teamId of validTeamIds) {
teamCache.revalidate({ id: teamId });
}
teamCache.revalidate({ userId, organizationId: invite.organizationId });
projectCache.revalidate({ userId, organizationId: invite.organizationId });
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};

View File

@@ -0,0 +1,7 @@
import { Invite, User } from "@prisma/client";
export interface InviteWithCreator extends Pick<Invite, "id" | "organizationId" | "role" | "teamIds"> {
creator: Pick<User, "name" | "email" | "locale">;
}
export interface CreateMembershipInvite extends Pick<Invite, "organizationId" | "role" | "teamIds"> {}

View File

@@ -6,8 +6,9 @@ import { ZUserEmail } from "@formbricks/types/user";
export const VerificationRequestedPage = async ({ searchParams }) => {
const t = await getTranslations();
const { token } = await searchParams;
try {
const email = getEmailFromEmailToken(searchParams.token);
const email = getEmailFromEmailToken(token);
const parsedEmail = ZUserEmail.safeParse(email);
if (parsedEmail.success) {
return (

View File

@@ -4,10 +4,12 @@ import { getTranslations } from "next-intl/server";
export const VerifyPage = async ({ searchParams }) => {
const t = await getTranslations();
return searchParams && searchParams.token ? (
const { token } = await searchParams;
return token ? (
<FormWrapper>
<p className="text-center">{t("auth.verify.verifying")}</p>
<SignIn token={searchParams.token} />
<SignIn token={token} />
</FormWrapper>
) : (
<p className="text-center">{t("auth.verify.no_token_provided")}</p>

View File

@@ -102,7 +102,6 @@ export const PricingTable = ({
throw new Error(t("common.something_went_wrong_please_try_again"));
}
} catch (err) {
console.log({ err });
toast.error(t("environments.settings.billing.unable_to_upgrade_plan"));
}
};

View File

@@ -45,12 +45,12 @@ export const getContactsAction = authenticatedActionClient
return getContacts(parsedInput.environmentId, parsedInput.offset, parsedInput.searchValue);
});
const ZPersonDeleteAction = z.object({
const ZContactDeleteAction = z.object({
contactId: ZId,
});
export const deleteContactAction = authenticatedActionClient
.schema(ZPersonDeleteAction)
.schema(ZContactDeleteAction)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromContactId(parsedInput.contactId);
const projectId = await getProjectIdFromContactId(parsedInput.contactId);

View File

@@ -35,7 +35,7 @@ export const getContactByUserIdWithAttributes = reactCache(
return contact;
},
[`getContactByUserId-${environmentId}-${userId}-${JSON.stringify(updatedAttributes)}`],
[`getContactByUserIdWithAttributes-${environmentId}-${userId}-${JSON.stringify(updatedAttributes)}`],
{
tags: [
contactCache.tag.byEnvironmentIdAndUserId(environmentId, userId),

View File

@@ -1,10 +1,10 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { updateAttributes } from "@/modules/ee/contacts/lib/attributes";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { NextRequest } from "next/server";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ZJsContactsUpdateAttributeInput } from "@formbricks/types/js";
import { updateAttributes } from "./lib/attributes";
import { getContactByUserIdWithAttributes } from "./lib/contact";
export const OPTIONS = async () => {
@@ -74,36 +74,15 @@ export const PUT = async (
);
}
const { details: updateAttrDetails } = await updateAttributes(
contact.id,
userId,
environmentId,
updatedAttributes
);
// if userIdAttr or idAttr was in the payload, we need to inform the user that it was ignored
const details: Record<string, string> = {};
if (userIdAttr) {
details.userId = "updating userId is ignored as it is a reserved field and cannot be updated.";
}
if (idAttr) {
details.id = "updating id is ignored as it is a reserved field and cannot be updated.";
}
if (updateAttrDetails && Object.keys(updateAttrDetails).length > 0) {
Object.entries(updateAttrDetails).forEach(([key, value]) => {
details[key] = value;
});
}
const { messages } = await updateAttributes(contact.id, userId, environmentId, updatedAttributes);
return responses.successResponse(
{
changed: true,
message: "The person was successfully updated.",
...(Object.keys(details).length > 0
...(messages && messages.length > 0
? {
details,
messages,
}
: {}),
},

View File

@@ -4,7 +4,7 @@ import { contactCache } from "@/lib/cache/contact";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { NextRequest, userAgent } from "next/server";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ZJsPersonIdentifyInput } from "@formbricks/types/js";
import { ZJsUserIdentifyInput } from "@formbricks/types/js";
import { getPersonState } from "./lib/personState";
export const OPTIONS = async (): Promise<Response> => {
@@ -21,7 +21,7 @@ export const GET = async (
const { environmentId, userId } = params;
// Validate input
const syncInputValidation = ZJsPersonIdentifyInput.safeParse({
const syncInputValidation = ZJsUserIdentifyInput.safeParse({
environmentId,
userId,
});

View File

@@ -0,0 +1,39 @@
import { contactCache } from "@/lib/cache/contact";
import { contactAttributeCache } from "@/lib/cache/contact-attribute";
import { contactAttributeKeyCache } from "@/lib/cache/contact-attribute-key";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
export const getContactByUserIdWithAttributes = reactCache((environmentId: string, userId: string) =>
cache(
async () => {
const contact = await prisma.contact.findFirst({
where: {
environmentId,
attributes: { some: { attributeKey: { key: "userId", environmentId }, value: userId } },
},
select: {
id: true,
attributes: {
select: { attributeKey: { select: { key: true } }, value: true },
},
},
});
if (!contact) {
return null;
}
return contact;
},
[`getContactByUserIdWithAttributes-${environmentId}-${userId}`],
{
tags: [
contactCache.tag.byEnvironmentIdAndUserId(environmentId, userId),
contactAttributeCache.tag.byEnvironmentIdAndUserId(environmentId, userId),
contactAttributeKeyCache.tag.byEnvironmentId(environmentId),
],
}
)()
);

View File

@@ -0,0 +1,82 @@
import { contactAttributeCache } from "@/lib/cache/contact-attribute";
import { evaluateSegment } from "@/modules/ee/contacts/segments/lib/segments";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { segmentCache } from "@formbricks/lib/cache/segment";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { ZId, ZString } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
import { TBaseFilter } from "@formbricks/types/segment";
const getSegments = reactCache((environmentId: string) =>
cache(
async () => {
try {
return prisma.segment.findMany({
where: { environmentId },
select: { id: true, filters: true },
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getSegments-environmentId-${environmentId}`],
{
tags: [segmentCache.tag.byEnvironmentId(environmentId)],
}
)()
);
export const getPersonSegmentIds = (
environmentId: string,
contactId: string,
contactUserId: string,
attributes: Record<string, string>,
deviceType: "phone" | "desktop"
): Promise<string[]> =>
cache(
async () => {
validateInputs([environmentId, ZId], [contactId, ZId], [contactUserId, ZString]);
const segments = await getSegments(environmentId);
// fast path; if there are no segments, return an empty array
if (!segments) {
return [];
}
const personSegments: { id: string; filters: TBaseFilter[] }[] = [];
for (const segment of segments) {
const isIncluded = await evaluateSegment(
{
attributes,
deviceType,
environmentId,
contactId: contactId,
userId: contactUserId,
},
segment.filters
);
if (isIncluded) {
personSegments.push(segment);
}
}
return personSegments.map((segment) => segment.id);
},
[`getPersonSegmentIds-${environmentId}-${contactId}-${deviceType}`],
{
tags: [
segmentCache.tag.byEnvironmentId(environmentId),
contactAttributeCache.tag.byContactId(contactId),
],
}
)();

View File

@@ -0,0 +1,129 @@
import { contactCache } from "@/lib/cache/contact";
import { updateAttributes } from "@/modules/ee/contacts/lib/attributes";
import { prisma } from "@formbricks/database";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TJsPersonState } from "@formbricks/types/js";
import { getContactByUserIdWithAttributes } from "./contact";
import { getUserState } from "./user-state";
export const updateUser = async (
environmentId: string,
userId: string,
device: "phone" | "desktop",
attributes?: Record<string, string>
): Promise<{ state: TJsPersonState; messages?: string[] }> => {
const environment = await getEnvironment(environmentId);
if (!environment) {
throw new ResourceNotFoundError(`environment`, environmentId);
}
let contact = await getContactByUserIdWithAttributes(environmentId, userId);
if (!contact) {
contact = await prisma.contact.create({
data: {
environment: {
connect: {
id: environmentId,
},
},
attributes: {
create: [
{
attributeKey: {
connect: { key_environmentId: { key: "userId", environmentId } },
},
value: userId,
},
],
},
},
select: {
id: true,
attributes: {
select: { attributeKey: { select: { key: true } }, value: true },
},
},
});
contactCache.revalidate({
environmentId,
userId,
id: contact.id,
});
}
let contactAttributes = contact.attributes.reduce(
(acc, ctx) => {
acc[ctx.attributeKey.key] = ctx.value;
return acc;
},
{} as Record<string, string>
);
// update the contact attributes if needed:
let messages: string[] = [];
let language = contactAttributes.language;
if (attributes && Object.keys(attributes).length > 0) {
let shouldUpdate = false;
const oldAttributes = contact.attributes.reduce(
(acc, ctx) => {
acc[ctx.attributeKey.key] = ctx.value;
return acc;
},
{} as Record<string, string>
);
for (const [key, value] of Object.entries(attributes)) {
if (value !== oldAttributes[key]) {
shouldUpdate = true;
break;
}
}
if (shouldUpdate) {
const { success, messages: updateAttrMessages } = await updateAttributes(
contact.id,
userId,
environmentId,
attributes
);
messages = updateAttrMessages ?? [];
// If the attributes update was successful and the language attribute was provided, set the language
if (success) {
contactAttributes = {
...contactAttributes,
...attributes,
};
if (attributes.language) {
language = attributes.language;
}
}
}
}
const userState = await getUserState({
environmentId,
userId,
contactId: contact.id,
attributes: contactAttributes,
device,
});
return {
state: {
data: {
...userState,
language,
},
expiresAt: new Date(Date.now() + 1000 * 60 * 30), // 30 minutes
},
messages,
};
};

View File

@@ -0,0 +1,89 @@
import { contactCache } from "@/lib/cache/contact";
import { contactAttributeCache } from "@/lib/cache/contact-attribute";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { segmentCache } from "@formbricks/lib/cache/segment";
import { displayCache } from "@formbricks/lib/display/cache";
import { environmentCache } from "@formbricks/lib/environment/cache";
import { organizationCache } from "@formbricks/lib/organization/cache";
import { responseCache } from "@formbricks/lib/response/cache";
import { TJsPersonState } from "@formbricks/types/js";
import { getPersonSegmentIds } from "./segments";
/**
*
* @param environmentId - The environment id
* @param userId - The user id
* @param device - The device type
* @param attributes - The contact attributes
* @returns The person state
* @throws {ValidationError} - If the input is invalid
* @throws {ResourceNotFoundError} - If the environment or organization is not found
*/
export const getUserState = async ({
environmentId,
userId,
contactId,
device,
attributes,
}: {
environmentId: string;
userId: string;
contactId: string;
device: "phone" | "desktop";
attributes: Record<string, string>;
}): Promise<TJsPersonState["data"]> =>
cache(
async () => {
const contactResponses = await prisma.response.findMany({
where: {
contactId,
},
select: {
surveyId: true,
},
});
const contactDisplays = await prisma.display.findMany({
where: {
contactId,
},
select: {
surveyId: true,
createdAt: true,
},
});
const segments = await getPersonSegmentIds(environmentId, contactId, userId, attributes, device);
// If the person exists, return the persons's state
const userState: TJsPersonState["data"] = {
userId,
segments,
displays:
contactDisplays?.map((display) => ({
surveyId: display.surveyId,
createdAt: display.createdAt,
})) ?? [],
responses: contactResponses?.map((response) => response.surveyId) ?? [],
lastDisplayAt:
contactDisplays.length > 0
? contactDisplays.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0].createdAt
: null,
};
return userState;
},
[`personState-${environmentId}-${userId}-${device}`],
{
tags: [
environmentCache.tag.byId(environmentId),
organizationCache.tag.byEnvironmentId(environmentId),
contactCache.tag.byEnvironmentIdAndUserId(environmentId, userId),
contactAttributeCache.tag.byEnvironmentIdAndUserId(environmentId, userId),
displayCache.tag.byEnvironmentIdAndUserId(environmentId, userId),
responseCache.tag.byEnvironmentIdAndUserId(environmentId, userId),
segmentCache.tag.byEnvironmentId(environmentId),
],
}
)();

View File

@@ -0,0 +1,92 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { NextRequest, userAgent } from "next/server";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TJsPersonState, ZJsUserIdentifyInput, ZJsUserUpdateInput } from "@formbricks/types/js";
import { updateUser } from "./lib/update-user";
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse({}, true);
};
export const POST = async (
request: NextRequest,
props: { params: Promise<{ environmentId: string }> }
): Promise<Response> => {
const params = await props.params;
try {
const { environmentId } = params;
const jsonInput = await request.json();
// Validate input
const syncInputValidation = ZJsUserIdentifyInput.pick({ environmentId: true }).safeParse({
environmentId,
});
if (!syncInputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(syncInputValidation.error),
true
);
}
const parsedInput = ZJsUserUpdateInput.safeParse(jsonInput);
if (!parsedInput.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(parsedInput.error),
true
);
}
const { userId, attributes } = parsedInput.data;
const isContactsEnabled = await getIsContactsEnabled();
if (!isContactsEnabled) {
return responses.forbiddenResponse("User identification is only available for enterprise users.", true);
}
let attributeUpdatesToSend: TContactAttributes | null = null;
if (attributes) {
// remove userId and id from attributes
const { userId: userIdAttr, id: idAttr, ...updatedAttributes } = attributes;
attributeUpdatesToSend = updatedAttributes;
}
const { device } = userAgent(request);
const deviceType = device ? "phone" : "desktop";
try {
const { state: userState, messages } = await updateUser(
environmentId,
userId,
deviceType,
attributeUpdatesToSend ?? undefined
);
let responseJson: { state: TJsPersonState; messages?: string[] } = {
state: userState,
};
if (messages && messages.length > 0) {
responseJson.messages = messages;
}
return responses.successResponse(responseJson, true);
} catch (err) {
if (err instanceof ResourceNotFoundError) {
return responses.notFoundResponse(err.resourceType, err.resourceId);
}
console.error(err);
return responses.internalServerErrorResponse(err.message ?? "Unable to fetch person state", true);
}
} catch (error) {
console.error(error);
return responses.internalServerErrorResponse(`Unable to complete response: ${error.message}`, true);
}
};

View File

@@ -1,44 +1,18 @@
import "server-only";
import { contactAttributeCache } from "@/lib/cache/contact-attribute";
import { contactAttributeKeyCache } from "@/lib/cache/contact-attribute-key";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT } from "@formbricks/lib/constants";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { ZId, ZString } from "@formbricks/types/common";
import { TContactAttributes, ZContactAttributes } from "@formbricks/types/contact-attribute";
export const getContactAttributeKeys = reactCache((environmentId: string) =>
cache(
async () => {
validateInputs([environmentId, ZId]);
const contactAttributes = await prisma.contactAttributeKey.findMany({
where: {
environmentId,
},
select: {
id: true,
key: true,
},
});
return contactAttributes;
},
[`getContactAttributeKeys-attributes-api-${environmentId}`],
{
tags: [contactAttributeKeyCache.tag.byEnvironmentId(environmentId)],
}
)()
);
import { getContactAttributeKeys } from "./contacts";
export const updateAttributes = async (
contactId: string,
userId: string,
environmentId: string,
contactAttributesParam: TContactAttributes
): Promise<{ success: boolean; details?: Record<string, string> }> => {
): Promise<{ success: boolean; messages?: string[] }> => {
validateInputs(
[contactId, ZId],
[userId, ZString],
@@ -96,9 +70,9 @@ export const updateAttributes = async (
}
);
let details: Record<string, string> = emailExists
? { email: "The email already exists for this environment and was not updated." }
: {};
let messages: string[] = emailExists
? ["The email already exists for this environment and was not updated."]
: [];
// First, update all existing attributes
if (existingAttributes.length > 0) {
@@ -122,9 +96,9 @@ export const updateAttributes = async (
);
// Revalidate cache for existing attributes
existingAttributes.map(({ key }) =>
contactAttributeCache.revalidate({ environmentId, contactId, userId, key })
);
for (const attribute of existingAttributes) {
contactAttributeCache.revalidate({ environmentId, contactId, userId, key: attribute.key });
}
}
// Then, try to create new attributes if any exist
@@ -133,10 +107,9 @@ export const updateAttributes = async (
if (totalAttributeClassesLength > MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT) {
// Add warning to details about skipped attributes
details = {
...details,
newAttributes: `Could not create ${newAttributes.length} new attribute(s) as it would exceed the maximum limit of ${MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT} attribute classes. Existing attributes were updated successfully.`,
};
messages.push(
`Could not create ${newAttributes.length} new attribute(s) as it would exceed the maximum limit of ${MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT} attribute classes. Existing attributes were updated successfully.`
);
} else {
// Create new attributes since we're under the limit
await prisma.$transaction(
@@ -155,16 +128,17 @@ export const updateAttributes = async (
);
// Batch revalidate caches for new attributes
newAttributes.forEach(({ key }) => {
contactAttributeKeyCache.revalidate({ environmentId, key });
contactAttributeCache.revalidate({ environmentId, contactId, userId, key });
});
for (const attribute of newAttributes) {
contactAttributeKeyCache.revalidate({ environmentId, key: attribute.key });
contactAttributeCache.revalidate({ environmentId, contactId, userId, key: attribute.key });
}
contactAttributeKeyCache.revalidate({ environmentId });
}
}
return {
success: true,
...(Object.keys(details).length > 0 ? { details } : {}),
messages,
};
};

View File

@@ -148,9 +148,12 @@ export const deleteContact = async (contactId: string): Promise<TContact | null>
select: selectContact,
});
const contactUserId = contact.attributes.find((attr) => attr.attributeKey.key === "userId")?.value;
contactCache.revalidate({
id: contact.id,
environmentId: contact.environmentId,
userId: contactUserId,
});
return contact;

View File

@@ -3,14 +3,14 @@
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
import { updateInvite } from "@/modules/ee/role-management/lib/invite";
import { updateMembership } from "@/modules/ee/role-management/lib/membership";
import { ZInviteUpdateInput } from "@/modules/ee/role-management/types/invites";
import { z } from "zod";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { updateInvite } from "@formbricks/lib/invite/service";
import { getOrganization } from "@formbricks/lib/organization/service";
import { ZId, ZUuid } from "@formbricks/types/common";
import { OperationNotAllowedError, ValidationError } from "@formbricks/types/errors";
import { ZInviteUpdateInput } from "@formbricks/types/invites";
import { ZMembershipUpdateInput } from "@formbricks/types/memberships";
export const checkRoleManagementPermission = async (organizationId: string) => {

View File

@@ -0,0 +1,31 @@
import { inviteCache } from "@/lib/cache/invite";
import { type TInviteUpdateInput } from "@/modules/ee/role-management/types/invites";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { ResourceNotFoundError } from "@formbricks/types/errors";
export const updateInvite = async (inviteId: string, data: TInviteUpdateInput): Promise<boolean> => {
try {
const invite = await prisma.invite.update({
where: { id: inviteId },
data,
});
if (invite === null) {
throw new ResourceNotFoundError("Invite", inviteId);
}
inviteCache.revalidate({
id: invite.id,
organizationId: invite.organizationId,
});
return true;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2016") {
throw new ResourceNotFoundError("Invite", inviteId);
} else {
throw error; // Re-throw any other errors
}
}
};

View File

@@ -0,0 +1,8 @@
import { z } from "zod";
import { ZInvite } from "@formbricks/database/zod/invites";
export const ZInviteUpdateInput = ZInvite.pick({
role: true,
});
export type TInviteUpdateInput = z.infer<typeof ZInviteUpdateInput>;

View File

@@ -90,7 +90,6 @@ export const sendTestEmailAction = authenticatedActionClient
await sendEmailCustomizationPreviewEmail(
ctx.user.email,
"Formbricks Email Customization Preview",
ctx.user.name,
ctx.user.locale,
organization?.whitelabel?.logoUrl || ""

View File

@@ -1,10 +1,15 @@
import { translateEmailText } from "@/modules/email/lib/utils";
import { Text } from "@react-email/components";
export function EmailFooter(): React.JSX.Element {
interface EmailFooterProps {
locale: string;
}
export function EmailFooter({ locale }: EmailFooterProps): React.JSX.Element {
return (
<Text>
Have a great day!
<br /> The Formbricks Team
{translateEmailText("email_footer_text_1", locale)}
<br /> {translateEmailText("email_footer_text_2", locale)}
</Text>
);
}

View File

@@ -1,3 +1,4 @@
import { translateEmailText } from "@/modules/email/lib/utils";
import { Body, Container, Html, Img, Link, Section, Tailwind, Text } from "@react-email/components";
import { IMPRINT_ADDRESS, IMPRINT_URL, PRIVACY_URL } from "@formbricks/lib/constants";
@@ -8,9 +9,10 @@ const logoLink = "https://formbricks.com?utm_source=email_header&utm_medium=emai
interface EmailTemplateProps {
children: React.ReactNode;
logoUrl?: string;
locale: string;
}
export function EmailTemplate({ children, logoUrl }: EmailTemplateProps): React.JSX.Element {
export function EmailTemplate({ children, logoUrl, locale }: EmailTemplateProps): React.JSX.Element {
const isDefaultLogo = !logoUrl || logoUrl === fbLogoUrl;
return (
@@ -35,21 +37,22 @@ export function EmailTemplate({ children, logoUrl }: EmailTemplateProps): React.
</Container>
<Section className="mt-4 text-center text-sm">
<Text className="m-0 font-normal text-slate-500">This email was sent via Formbricks.</Text>
<Text className="m-0 font-normal text-slate-500">
{translateEmailText("email_template_text_1", locale)}
</Text>
{IMPRINT_ADDRESS && (
<Text className="m-0 font-normal text-slate-500 opacity-50">{IMPRINT_ADDRESS}</Text>
)}
<Text className="m-0 font-normal text-slate-500 opacity-50">
{IMPRINT_URL && (
<Link href={IMPRINT_URL} target="_blank" rel="noopener noreferrer" className="text-slate-500">
Imprint{" "}
{translateEmailText("imprint", locale)}
</Link>
)}
{IMPRINT_URL && PRIVACY_URL && "•"}
{PRIVACY_URL && (
<Link href={PRIVACY_URL} target="_blank" rel="noopener noreferrer" className="text-slate-500">
{" "}
Privacy Policy
{translateEmailText("privacy_policy", locale)}
</Link>
)}
</Text>

View File

@@ -18,28 +18,34 @@ import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { COLOR_DEFAULTS } from "@formbricks/lib/styling/constants";
import { isLight, mixColor } from "@formbricks/lib/utils/colors";
import { type TSurvey, TSurveyQuestionTypeEnum, type TSurveyStyling } from "@formbricks/types/surveys/types";
import { getNPSOptionColor, getRatingNumberOptionColor } from "../lib/utils";
import { getNPSOptionColor, getRatingNumberOptionColor, translateEmailText } from "../lib/utils";
interface PreviewEmailTemplateProps {
survey: TSurvey;
surveyUrl: string;
styling: TSurveyStyling;
locale: string;
}
export const getPreviewEmailTemplateHtml = async (
survey: TSurvey,
surveyUrl: string,
styling: TSurveyStyling
styling: TSurveyStyling,
locale: string
): Promise<string> => {
return render(<PreviewEmailTemplate styling={styling} survey={survey} surveyUrl={surveyUrl} />, {
pretty: true,
});
return render(
<PreviewEmailTemplate styling={styling} survey={survey} surveyUrl={surveyUrl} locale={locale} />,
{
pretty: true,
}
);
};
export function PreviewEmailTemplate({
survey,
surveyUrl,
styling,
locale,
}: PreviewEmailTemplateProps): React.JSX.Element {
const url = `${surveyUrl}?preview=true`;
const urlWithPrefilling = `${surveyUrl}?preview=true&skipPrefilled=true&`;
@@ -87,7 +93,7 @@ export function PreviewEmailTemplate({
<EmailButton
className="rounded-custom inline-flex cursor-pointer appearance-none px-6 py-3 text-sm font-medium text-black"
href={`${urlWithPrefilling}${firstQuestion.id}=dismissed`}>
Reject
{translateEmailText("reject", locale)}
</EmailButton>
)}
<EmailButton
@@ -96,7 +102,7 @@ export function PreviewEmailTemplate({
isLight(brandColor) ? "text-black" : "text-white"
)}
href={`${urlWithPrefilling}${firstQuestion.id}=accepted`}>
Accept
{translateEmailText("accept", locale)}
</EmailButton>
</Container>
<EmailFooter />
@@ -365,17 +371,17 @@ export function PreviewEmailTemplate({
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
<Container>
<Text className="text-question-color m-0 mb-2 block p-0 text-sm font-normal leading-6">
{getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)}
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
</Text>
<Text className="text-question-color m-0 mb-2 block p-0 text-sm font-normal leading-6">
You have been invited to schedule a meet via cal.com.
{getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)}
</Text>
<EmailButton
className={cn(
"bg-brand-color rounded-custom mx-auto block w-max cursor-pointer appearance-none px-6 py-3 text-sm font-medium",
isLight(brandColor) ? "text-black" : "text-white"
)}>
Schedule your meeting
{translateEmailText("schedule_your_meeting", defaultLanguageCode)}
</EmailButton>
</Container>
<EmailFooter />
@@ -392,7 +398,9 @@ export function PreviewEmailTemplate({
</Text>
<Section className="border-input-border-color bg-input-color rounded-custom mt-4 flex h-12 w-full items-center justify-center border border-solid">
<CalendarDaysIcon className="text-question-color inline h-4 w-4" />
<Text className="text-question-color inline text-sm font-medium">Select a date</Text>
<Text className="text-question-color inline text-sm font-medium">
{translateEmailText("select_a_date", defaultLanguageCode)}
</Text>
</Section>
<EmailFooter />
</EmailTemplateWrapper>
@@ -478,7 +486,9 @@ export function PreviewEmailTemplate({
<Section className="border-input-border-color rounded-custom mt-4 flex h-24 w-full items-center justify-center border border-dashed bg-slate-50">
<Container className="mx-auto flex items-center text-center">
<UploadIcon className="mt-6 inline h-5 w-5 text-slate-400" />
<Text className="text-slate-400">Click or drag to upload files.</Text>
<Text className="text-slate-400">
{translateEmailText("click_or_drag_to_upload_files", defaultLanguageCode)}
</Text>
</Container>
</Section>
<EmailFooter />

View File

@@ -12,7 +12,7 @@ interface ForgotPasswordEmailProps {
export function ForgotPasswordEmail({ verifyLink, locale }: ForgotPasswordEmailProps): React.JSX.Element {
return (
<EmailTemplate>
<EmailTemplate locale={locale}>
<Container>
<Heading>{translateEmailText("forgot_password_email_heading", locale)}</Heading>
<Text>{translateEmailText("forgot_password_email_text", locale)}</Text>
@@ -24,7 +24,7 @@ export function ForgotPasswordEmail({ verifyLink, locale }: ForgotPasswordEmailP
{translateEmailText("forgot_password_email_link_valid_for_24_hours", locale)}
</Text>
<Text className="mb-0">{translateEmailText("forgot_password_email_did_not_request", locale)}</Text>
<EmailFooter />
<EmailFooter locale={locale} />
</Container>
</EmailTemplate>
);

View File

@@ -10,11 +10,11 @@ interface PasswordResetNotifyEmailProps {
export function PasswordResetNotifyEmail({ locale }: PasswordResetNotifyEmailProps): React.JSX.Element {
return (
<EmailTemplate>
<EmailTemplate locale={locale}>
<Container>
<Heading>{translateEmailText("password_changed_email_heading", locale)}</Heading>
<Text>{translateEmailText("password_changed_email_text", locale)}</Text>
<EmailFooter />
<EmailFooter locale={locale} />
</Container>
</EmailTemplate>
);

View File

@@ -17,7 +17,7 @@ export function VerificationEmail({
locale,
}: VerificationEmailProps): React.JSX.Element {
return (
<EmailTemplate>
<EmailTemplate locale={locale}>
<Container>
<Heading>{translateEmailText("verification_email_heading", locale)}</Heading>
<Text>{translateEmailText("verification_email_text", locale)}</Text>
@@ -38,7 +38,7 @@ export function VerificationEmail({
{translateEmailText("verification_email_request_new_verification", locale)}
</Link>
</Text>
<EmailFooter />
<EmailFooter locale={locale} />
</Container>
</EmailTemplate>
);

View File

@@ -15,7 +15,7 @@ export function EmailCustomizationPreviewEmail({
logoUrl,
}: EmailCustomizationPreviewEmailProps): React.JSX.Element {
return (
<EmailTemplate logoUrl={logoUrl}>
<EmailTemplate logoUrl={logoUrl} locale={locale}>
<Container>
<Heading>
{translateEmailText("email_customization_preview_email_heading", locale, {

View File

@@ -16,7 +16,7 @@ export function InviteAcceptedEmail({
locale,
}: InviteAcceptedEmailProps): React.JSX.Element {
return (
<EmailTemplate>
<EmailTemplate locale={locale}>
<Container>
<Text>
{translateEmailText("invite_accepted_email_heading", locale)} {inviterName},
@@ -25,7 +25,7 @@ export function InviteAcceptedEmail({
{translateEmailText("invite_accepted_email_text_par1", locale)} {inviteeName}{" "}
{translateEmailText("invite_accepted_email_text_par2", locale)}
</Text>
<EmailFooter />
<EmailFooter locale={locale} />
</Container>
</EmailTemplate>
);

View File

@@ -19,7 +19,7 @@ export function InviteEmail({
locale,
}: InviteEmailProps): React.JSX.Element {
return (
<EmailTemplate>
<EmailTemplate locale={locale}>
<Container>
<Text>
{translateEmailText("invite_email_heading", locale)} {inviteeName},
@@ -28,8 +28,8 @@ export function InviteEmail({
{translateEmailText("invite_email_text_par1", locale)} {inviterName}{" "}
{translateEmailText("invite_email_text_par2", locale)}
</Text>
<EmailButton href={verifyLink} label="Join organization" />
<EmailFooter />
<EmailButton href={verifyLink} label={translateEmailText("invite_email_button_label", locale)} />
<EmailFooter locale={locale} />
</Container>
</EmailTemplate>
);

View File

@@ -20,7 +20,7 @@ export function OnboardingInviteEmail({
inviteeName,
}: OnboardingInviteEmailProps): React.JSX.Element {
return (
<EmailTemplate>
<EmailTemplate locale={locale}>
<Container>
<Heading>
{translateEmailText("onboarding_invite_email_heading", locale)} {inviteeName} 👋
@@ -34,8 +34,11 @@ export function OnboardingInviteEmail({
<li>{translateEmailText("onboarding_invite_email_connect_formbricks", locale)}</li>
<li>{translateEmailText("onboarding_invite_email_done", locale)} </li>
</ol>
<EmailButton href={verifyLink} label={`Join ${inviterName}'s organization`} />
<EmailFooter />
<EmailButton
href={verifyLink}
label={translateEmailText("onboarding_invite_email_button_label", locale, { inviterName })}
/>
<EmailFooter locale={locale} />
</Container>
</EmailTemplate>
);

View File

@@ -17,7 +17,7 @@ export function EmbedSurveyPreviewEmail({
logoUrl,
}: EmbedSurveyPreviewEmailProps): React.JSX.Element {
return (
<EmailTemplate logoUrl={logoUrl}>
<EmailTemplate logoUrl={logoUrl} locale={locale}>
<Container>
<Heading>{translateEmailText("embed_survey_preview_email_heading", locale)}</Heading>
<Text>{translateEmailText("embed_survey_preview_email_text", locale)}</Text>

View File

@@ -19,7 +19,7 @@ export function LinkSurveyEmail({
logoUrl,
}: LinkSurveyEmailProps): React.JSX.Element {
return (
<EmailTemplate logoUrl={logoUrl}>
<EmailTemplate logoUrl={logoUrl} locale={locale}>
<Container>
<Heading>{translateEmailText("verification_email_hey", locale)}</Heading>
<Text>{translateEmailText("verification_email_thanks", locale)}</Text>
@@ -28,7 +28,7 @@ export function LinkSurveyEmail({
<Text className="text-xs text-slate-400">
{translateEmailText("verification_email_survey_name", locale)}: {surveyName}
</Text>
<EmailFooter />
<EmailFooter locale={locale} />
</Container>
</EmailTemplate>
);

View File

@@ -93,7 +93,7 @@ export function ResponseFinishedEmail({
const questions = getQuestionResponseMapping(survey, response);
return (
<EmailTemplate>
<EmailTemplate locale={locale}>
<Container>
<Row>
<Column>

View File

@@ -24,7 +24,7 @@ export function WeeklySummaryNotificationEmail({
locale,
}: WeeklySummaryNotificationEmailProps): React.JSX.Element {
return (
<EmailTemplate>
<EmailTemplate locale={locale}>
<NotificationHeader
endDate={endDate}
endYear={endYear}

View File

@@ -46,8 +46,10 @@ interface SendEmailDataProps {
html: string;
}
const getEmailSubject = (projectName: string): string => {
return `${projectName} User Insights - Last Week by Formbricks`;
const getEmailSubject = (projectName: string, locale: string): string => {
return translateEmailText("weekly_summary_email_subject", locale, {
projectName,
});
};
export const sendEmail = async (emailData: SendEmailDataProps): Promise<boolean> => {
@@ -117,7 +119,7 @@ export const sendForgotPasswordEmail = async (user: {
const html = await render(ForgotPasswordEmail({ verifyLink, locale: user.locale }));
return await sendEmail({
to: user.email,
subject: "Reset your Formbricks password",
subject: translateEmailText("forgot_password_email_subject", user.locale),
html,
});
};
@@ -129,7 +131,7 @@ export const sendPasswordResetNotifyEmail = async (user: {
const html = await render(PasswordResetNotifyEmail({ locale: user.locale }));
return await sendEmail({
to: user.email,
subject: "Your Formbricks password has been changed",
subject: translateEmailText("password_reset_notify_email_subject", user.locale),
html,
});
};
@@ -155,14 +157,16 @@ export const sendInviteMemberEmail = async (
);
return await sendEmail({
to: email,
subject: `${inviterName} needs a hand setting up Formbricks. Can you help out?`,
subject: translateEmailText("onboarding_invite_email_subject", locale, {
inviterName,
}),
html,
});
} else {
const html = await render(InviteEmail({ inviteeName, inviterName, verifyLink, locale }));
return await sendEmail({
to: email,
subject: `You're invited to collaborate on Formbricks!`,
subject: translateEmailText("invite_member_email_subject", locale),
html,
});
}
@@ -177,7 +181,7 @@ export const sendInviteAcceptedEmail = async (
const html = await render(InviteAcceptedEmail({ inviteeName, inviterName, locale }));
await sendEmail({
to: email,
subject: `You've got a new organization member!`,
subject: translateEmailText("invite_accepted_email_subject", locale),
html,
});
};
@@ -212,8 +216,13 @@ export const sendResponseFinishedEmail = async (
await sendEmail({
to: email,
subject: personEmail
? `${personEmail} just completed your ${survey.name} survey ✅`
: `A response for ${survey.name} was completed ✅`,
? translateEmailText("response_finished_email_subject_with_email", locale, {
personEmail,
surveyName: survey.name,
})
: translateEmailText("response_finished_email_subject", locale, {
surveyName: survey.name,
}),
replyTo: personEmail?.toString() ?? MAIL_FROM,
html,
});
@@ -221,7 +230,6 @@ export const sendResponseFinishedEmail = async (
export const sendEmbedSurveyPreviewEmail = async (
to: string,
subject: string,
innerHtml: string,
environmentId: string,
locale: string,
@@ -230,14 +238,13 @@ export const sendEmbedSurveyPreviewEmail = async (
const html = await render(EmbedSurveyPreviewEmail({ html: innerHtml, environmentId, locale, logoUrl }));
return await sendEmail({
to,
subject,
subject: translateEmailText("embed_survey_preview_email_subject", locale),
html,
});
};
export const sendEmailCustomizationPreviewEmail = async (
to: string,
subject: string,
userName: string,
locale: string,
logoUrl?: string
@@ -246,7 +253,7 @@ export const sendEmailCustomizationPreviewEmail = async (
return await sendEmail({
to,
subject,
subject: translateEmailText("email_customization_preview_email_subject", locale),
html: emailHtmlBody,
});
};
@@ -270,7 +277,7 @@ export const sendLinkSurveyToVerifiedEmail = async (data: TLinkSurveyEmailData):
const html = await render(LinkSurveyEmail({ surveyName, surveyLink, locale, logoUrl }));
return await sendEmail({
to: data.email,
subject: "Your survey is ready to be filled out.",
subject: translateEmailText("verified_link_survey_email_subject", locale),
html,
});
};
@@ -302,7 +309,7 @@ export const sendWeeklySummaryNotificationEmail = async (
);
await sendEmail({
to: email,
subject: getEmailSubject(notificationData.projectName),
subject: getEmailSubject(notificationData.projectName, locale),
html,
});
};
@@ -334,7 +341,7 @@ export const sendNoLiveSurveyNotificationEmail = async (
);
await sendEmail({
to: email,
subject: getEmailSubject(notificationData.projectName),
subject: getEmailSubject(notificationData.projectName, locale),
html,
});
};

View File

@@ -14,13 +14,13 @@ import {
import { OrganizationRole } from "@prisma/client";
import { z } from "zod";
import { INVITE_DISABLED, IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { deleteInvite, getInvite, inviteUser, resendInvite } from "@formbricks/lib/invite/service";
import { createInviteToken } from "@formbricks/lib/jwt";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { ZId, ZUuid } from "@formbricks/types/common";
import { AuthenticationError, OperationNotAllowedError, ValidationError } from "@formbricks/types/errors";
import { ZOrganizationRole } from "@formbricks/types/memberships";
import { deleteInvite, getInvite, inviteUser, resendInvite } from "./lib/invite";
const ZDeleteInviteAction = z.object({
inviteId: ZUuid,
@@ -206,7 +206,7 @@ export const inviteUserAction = authenticatedActionClient
await checkRoleManagementPermission(parsedInput.organizationId);
}
const invite = await inviteUser({
const inviteId = await inviteUser({
organizationId: parsedInput.organizationId,
invitee: {
email: parsedInput.email,
@@ -217,9 +217,9 @@ export const inviteUserAction = authenticatedActionClient
currentUserId: ctx.user.id,
});
if (invite) {
if (inviteId) {
await sendInviteMemberEmail(
invite.id,
inviteId,
parsedInput.email,
ctx.user.name ?? "",
parsedInput.name ?? "",
@@ -229,7 +229,7 @@ export const inviteUserAction = authenticatedActionClient
);
}
return invite;
return inviteId;
});
const ZLeaveOrganizationAction = z.object({

View File

@@ -1,8 +1,8 @@
import { MembersInfo } from "@/modules/organization/settings/teams/components/edit-memberships/members-info";
import { getInvitesByOrganizationId } from "@/modules/organization/settings/teams/lib/invite";
import { getMembershipByOrganizationId } from "@/modules/organization/settings/teams/lib/membership";
import { getTranslations } from "next-intl/server";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { getInvitesByOrganizationId } from "@formbricks/lib/invite/service";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";

View File

@@ -8,6 +8,7 @@ import {
resendInviteAction,
} from "@/modules/organization/settings/teams/actions";
import { ShareInviteModal } from "@/modules/organization/settings/teams/components/invite-member/share-invite-modal";
import { TInvite } from "@/modules/organization/settings/teams/types/invites";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
@@ -16,7 +17,6 @@ import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import React, { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { TInvite } from "@formbricks/types/invites";
import { TMember } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
@@ -56,7 +56,6 @@ export const MemberActions = ({ organization, member, invite, showDeleteButton }
setIsDeleting(false);
router.refresh();
} catch (err) {
console.log({ err });
setIsDeleting(false);
toast.error(t("common.something_went_wrong_please_try_again"));
}

View File

@@ -1,12 +1,12 @@
import { isInviteExpired } from "@/app/lib/utils";
import { EditMembershipRole } from "@/modules/ee/role-management/components/edit-membership-role";
import { MemberActions } from "@/modules/organization/settings/teams/components/edit-memberships/member-actions";
import { isInviteExpired } from "@/modules/organization/settings/teams/lib/utilts";
import { TInvite } from "@/modules/organization/settings/teams/types/invites";
import { Badge } from "@/modules/ui/components/badge";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { useTranslations } from "next-intl";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getFormattedDateTimeString } from "@formbricks/lib/utils/datetime";
import { TInvite } from "@formbricks/types/invites";
import { TMember, TOrganizationRole } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";

View File

@@ -4,6 +4,7 @@ import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { TOrganizationTeam } from "@/modules/ee/teams/team-list/types/team";
import { inviteUserAction, leaveOrganizationAction } from "@/modules/organization/settings/teams/actions";
import { InviteMemberModal } from "@/modules/organization/settings/teams/components/invite-member/invite-member-modal";
import { TInvitee } from "@/modules/organization/settings/teams/types/invites";
import { Button } from "@/modules/ui/components/button";
import { CustomDialog } from "@/modules/ui/components/custom-dialog";
import { XIcon } from "lucide-react";
@@ -11,7 +12,7 @@ import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { TInvitee } from "@formbricks/types/invites";
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@formbricks/lib/localStorage";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
@@ -53,6 +54,7 @@ export const OrganizationActions = ({
toast.success(t("environments.settings.general.member_deleted_successfully"));
router.refresh();
setLoading(false);
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
router.push("/");
} catch (err) {
toast.error(`Error: ${err.message}`);
@@ -84,7 +86,7 @@ export const OrganizationActions = ({
email: email.toLowerCase(),
name,
role,
teamIds: teamIds,
teamIds,
});
return {
email,

View File

@@ -1,5 +1,6 @@
"use client";
import { ZInvitees } from "@/modules/organization/settings/teams/types/invites";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { Uploader } from "@/modules/ui/components/file-input/components/uploader";
@@ -9,7 +10,6 @@ import Link from "next/link";
import Papa, { type ParseResult } from "papaparse";
import { useState } from "react";
import toast from "react-hot-toast";
import { ZInvitees } from "@formbricks/types/invites";
import { TOrganizationRole } from "@formbricks/types/memberships";
interface BulkInviteTabProps {

View File

@@ -1,173 +1,21 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { inviteCache } from "@/lib/cache/invite";
import { Invite, Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { ZOptionalNumber, ZString } from "@formbricks/types/common";
import { cache } from "@formbricks/lib/cache";
import { ITEMS_PER_PAGE } from "@formbricks/lib/constants";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { validateInputs } from "@formbricks/lib/utils/validate";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
ValidationError,
} from "@formbricks/types/errors";
import {
TInvite,
TInviteUpdateInput,
TInvitee,
ZInviteUpdateInput,
ZInvitee,
} from "@formbricks/types/invites";
import { cache } from "../cache";
import { ITEMS_PER_PAGE } from "../constants";
import { getMembershipByUserIdOrganizationId } from "../membership/service";
import { validateInputs } from "../utils/validate";
import { inviteCache } from "./cache";
const inviteSelect: Prisma.InviteSelect = {
id: true,
email: true,
name: true,
organizationId: true,
creatorId: true,
acceptorId: true,
createdAt: true,
expiresAt: true,
role: true,
teamIds: true,
};
interface InviteWithCreator extends TInvite {
creator: {
name: string | null;
email: string;
};
}
export const getInvitesByOrganizationId = reactCache(
async (organizationId: string, page?: number): Promise<TInvite[]> =>
cache(
async () => {
validateInputs([organizationId, ZString], [page, ZOptionalNumber]);
try {
const invites = await prisma.invite.findMany({
where: { organizationId },
select: inviteSelect,
take: page ? ITEMS_PER_PAGE : undefined,
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
});
return invites;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getInvitesByOrganizationId-${organizationId}-${page}`],
{
tags: [inviteCache.tag.byOrganizationId(organizationId)],
}
)()
);
export const updateInvite = async (inviteId: string, data: TInviteUpdateInput): Promise<TInvite | null> => {
validateInputs([inviteId, ZString], [data, ZInviteUpdateInput]);
try {
const invite = await prisma.invite.update({
where: { id: inviteId },
data,
select: inviteSelect,
});
if (invite === null) {
throw new ResourceNotFoundError("Invite", inviteId);
}
inviteCache.revalidate({
id: invite.id,
organizationId: invite.organizationId,
});
return invite;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2016") {
throw new ResourceNotFoundError("Invite", inviteId);
} else {
throw error; // Re-throw any other errors
}
}
};
export const deleteInvite = async (inviteId: string): Promise<TInvite> => {
validateInputs([inviteId, ZString]);
try {
const invite = await prisma.invite.delete({
where: {
id: inviteId,
},
});
if (invite === null) {
throw new ResourceNotFoundError("Invite", inviteId);
}
inviteCache.revalidate({
id: invite.id,
organizationId: invite.organizationId,
});
return invite;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const getInvite = reactCache(
async (inviteId: string): Promise<InviteWithCreator | null> =>
cache(
async () => {
validateInputs([inviteId, ZString]);
try {
const invite = await prisma.invite.findUnique({
where: {
id: inviteId,
},
include: {
creator: {
select: {
name: true,
email: true,
},
},
},
});
return invite;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getInvite-${inviteId}`],
{
tags: [inviteCache.tag.byId(inviteId)],
}
)()
);
export const resendInvite = async (inviteId: string): Promise<TInvite> => {
validateInputs([inviteId, ZString]);
import { type InviteWithCreator, type TInvite, type TInvitee } from "../types/invites";
export const resendInvite = async (inviteId: string): Promise<Pick<Invite, "email" | "name">> => {
try {
const invite = await prisma.invite.findUnique({
where: {
@@ -191,6 +39,12 @@ export const resendInvite = async (inviteId: string): Promise<TInvite> => {
data: {
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
},
select: {
id: true,
email: true,
name: true,
organizationId: true,
},
});
inviteCache.revalidate({
@@ -198,7 +52,10 @@ export const resendInvite = async (inviteId: string): Promise<TInvite> => {
organizationId: updatedInvite.organizationId,
});
return updatedInvite;
return {
email: updatedInvite.email,
name: updatedInvite.name,
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
@@ -208,6 +65,43 @@ export const resendInvite = async (inviteId: string): Promise<TInvite> => {
}
};
export const getInvitesByOrganizationId = reactCache(
async (organizationId: string, page?: number): Promise<TInvite[]> =>
cache(
async () => {
validateInputs([organizationId, z.string()], [page, z.number().optional()]);
try {
const invites = await prisma.invite.findMany({
where: { organizationId },
select: {
expiresAt: true,
role: true,
email: true,
name: true,
id: true,
createdAt: true,
},
take: page ? ITEMS_PER_PAGE : undefined,
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
});
return invites;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getInvitesByOrganizationId-${organizationId}-${page}`],
{
tags: [inviteCache.tag.byOrganizationId(organizationId)],
}
)()
);
export const inviteUser = async ({
invitee,
organizationId,
@@ -216,9 +110,7 @@ export const inviteUser = async ({
organizationId: string;
invitee: TInvitee;
currentUserId: string;
}): Promise<TInvite> => {
validateInputs([organizationId, ZString], [invitee, ZInvitee]);
}): Promise<string> => {
try {
const { name, email, role, teamIds } = invitee;
@@ -276,7 +168,7 @@ export const inviteUser = async ({
organizationId: invite.organizationId,
});
return invite;
return invite.id;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
@@ -285,3 +177,69 @@ export const inviteUser = async ({
throw error;
}
};
export const deleteInvite = async (inviteId: string): Promise<boolean> => {
try {
const invite = await prisma.invite.delete({
where: {
id: inviteId,
},
select: {
id: true,
organizationId: true,
},
});
if (!invite) {
throw new ResourceNotFoundError("Invite", inviteId);
}
inviteCache.revalidate({
id: invite.id,
organizationId: invite.organizationId,
});
return true;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const getInvite = reactCache(
async (inviteId: string): Promise<InviteWithCreator | null> =>
cache(
async () => {
try {
const invite = await prisma.invite.findUnique({
where: {
id: inviteId,
},
select: {
email: true,
creator: {
select: {
name: true,
},
},
},
});
return invite;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`teams-getInvite-${inviteId}`],
{
tags: [inviteCache.tag.byId(inviteId)],
}
)()
);

View File

@@ -1,4 +1,4 @@
import { TInvite } from "@formbricks/types/invites";
import { TInvite } from "@/modules/organization/settings/teams/types/invites";
export const isInviteExpired = (invite: TInvite) => {
const now = new Date();

View File

@@ -0,0 +1,25 @@
import { Invite } from "@prisma/client";
import { z } from "zod";
import { ZInvite } from "@formbricks/database/zod/invites";
import { ZUserName } from "@formbricks/types/user";
export interface TInvite
extends Omit<Invite, "deprecatedRole" | "organizationId" | "creatorId" | "acceptorId" | "teamIds"> {}
export interface InviteWithCreator extends Pick<Invite, "email"> {
creator: {
name: string;
};
}
export const ZInvitee = ZInvite.pick({
email: true,
role: true,
teamIds: true,
}).extend({
name: ZUserName,
});
export type TInvitee = z.infer<typeof ZInvitee>;
export const ZInvitees = z.array(ZInvitee);

View File

@@ -16,13 +16,15 @@ import { TProject } from "@formbricks/types/project";
interface DeleteProjectRenderProps {
isDeleteDisabled: boolean;
isOwnerOrManager: boolean;
project: TProject;
currentProject: TProject;
organizationProjects: TProject[];
}
export const DeleteProjectRender = ({
isDeleteDisabled,
isOwnerOrManager,
project,
currentProject,
organizationProjects,
}: DeleteProjectRenderProps) => {
const t = useTranslations();
const router = useRouter();
@@ -30,9 +32,20 @@ export const DeleteProjectRender = ({
const [isDeleting, setIsDeleting] = useState(false);
const handleDeleteProject = async () => {
setIsDeleting(true);
const deleteProjectResponse = await deleteProjectAction({ projectId: project.id });
const deleteProjectResponse = await deleteProjectAction({ projectId: currentProject.id });
if (deleteProjectResponse?.data) {
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
if (organizationProjects.length === 1) {
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
} else if (organizationProjects.length > 1) {
// prevents changing of organization when deleting project
const remainingProjects = organizationProjects.filter((project) => project.id !== currentProject.id);
const productionEnvironment = remainingProjects[0].environments.find(
(environment) => environment.type === "production"
);
if (productionEnvironment) {
localStorage.setItem(FORMBRICKS_ENVIRONMENT_ID_LS, productionEnvironment.id);
}
}
toast.success(t("environments.project.general.project_deleted_successfully"));
router.push("/");
} else {
@@ -51,7 +64,7 @@ export const DeleteProjectRender = ({
{t(
"environments.project.general.delete_project_name_includes_surveys_responses_people_and_more",
{
projectName: truncate(project.name, 30),
projectName: truncate(currentProject.name, 30),
}
)}{" "}
<strong>{t("environments.project.general.this_action_cannot_be_undone")}</strong>
@@ -81,7 +94,7 @@ export const DeleteProjectRender = ({
setOpen={setIsDeleteDialogOpen}
onDelete={handleDeleteProject}
text={t("environments.project.general.delete_project_confirmation", {
projectName: truncate(project.name, 30),
projectName: truncate(currentProject.name, 30),
})}
isDeleting={isDeleting}
/>

View File

@@ -8,11 +8,17 @@ import { TProject } from "@formbricks/types/project";
interface DeleteProjectProps {
environmentId: string;
project: TProject;
currentProject: TProject;
organizationProjects: TProject[];
isOwnerOrManager: boolean;
}
export const DeleteProject = async ({ environmentId, project, isOwnerOrManager }: DeleteProjectProps) => {
export const DeleteProject = async ({
environmentId,
currentProject,
organizationProjects,
isOwnerOrManager,
}: DeleteProjectProps) => {
const t = await getTranslations();
const session = await getServerSession(authOptions);
if (!session) {
@@ -31,7 +37,8 @@ export const DeleteProject = async ({ environmentId, project, isOwnerOrManager }
<DeleteProjectRender
isDeleteDisabled={isDeleteDisabled}
isOwnerOrManager={isOwnerOrManager}
project={project}
currentProject={currentProject}
organizationProjects={organizationProjects}
/>
);
};

View File

@@ -17,7 +17,7 @@ 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 { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { getProjectByEnvironmentId, getProjects } from "@formbricks/lib/project/service";
import { DeleteProject } from "./components/delete-project";
import { EditProjectNameForm } from "./components/edit-project-name-form";
import { EditWaitingTimeForm } from "./components/edit-waiting-time-form";
@@ -41,6 +41,8 @@ export const GeneralSettingsPage = async (props: { params: Promise<{ environment
throw new Error(t("common.organization_not_found"));
}
const organizationProjects = await getProjects(organization.id);
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const projectPermission = await getProjectPermissionByUserId(session.user.id, project.id);
@@ -79,7 +81,8 @@ export const GeneralSettingsPage = async (props: { params: Promise<{ environment
description={t("environments.project.general.delete_project_settings_description")}>
<DeleteProject
environmentId={params.environmentId}
project={project}
currentProject={project}
organizationProjects={organizationProjects}
isOwnerOrManager={isOwnerOrManager}
/>
</SettingsCard>

View File

@@ -0,0 +1,33 @@
import { Button } from "@/modules/ui/components/button";
import { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import Link from "next/link";
export const metadata: Metadata = {
title: "Intro",
description: "Open-source Experience Management. Free & open source.",
};
const renderRichText = async (text: string) => {
const t = await getTranslations();
return <p>{t.rich(text, { b: (chunks) => <b>{chunks}</b> })}</p>;
};
export const IntroPage = async () => {
const t = await getTranslations();
return (
<div className="flex flex-col items-center">
<h2 className="mb-6 text-xl font-medium">{t("setup.intro.welcome_to_formbricks")}</h2>
<div className="mx-auto max-w-sm space-y-4 text-sm leading-6 text-slate-600">
{renderRichText("setup.intro.paragraph_1")}
{renderRichText("setup.intro.paragraph_2")}
{renderRichText("setup.intro.paragraph_3")}
</div>
<Button className="mt-6" asChild>
<Link href="/setup/signup">{t("setup.intro.get_started")}</Link>
</Button>
<p className="pt-6 text-xs text-slate-400">{t("setup.intro.made_with_love_in_kiel")}</p>
</div>
);
};

View File

@@ -0,0 +1,14 @@
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getServerSession } from "next-auth";
import { notFound } from "next/navigation";
import { getIsFreshInstance } from "@formbricks/lib/instance/service";
export const FreshInstanceLayout = async ({ children }: { children: React.ReactNode }) => {
const session = await getServerSession(authOptions);
const isFreshInstance = await getIsFreshInstance();
if (session || !isFreshInstance) {
return notFound();
}
return <>{children}</>;
};

View File

@@ -0,0 +1,55 @@
import { SignupForm } from "@/modules/auth/signup/components/signup-form";
import { getIsSSOEnabled } from "@/modules/ee/license-check/lib/utils";
import { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import {
AZURE_OAUTH_ENABLED,
DEFAULT_ORGANIZATION_ID,
DEFAULT_ORGANIZATION_ROLE,
EMAIL_AUTH_ENABLED,
EMAIL_VERIFICATION_DISABLED,
GITHUB_OAUTH_ENABLED,
GOOGLE_OAUTH_ENABLED,
IS_TURNSTILE_CONFIGURED,
OIDC_DISPLAY_NAME,
OIDC_OAUTH_ENABLED,
PRIVACY_URL,
TERMS_URL,
WEBAPP_URL,
} from "@formbricks/lib/constants";
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
export const metadata: Metadata = {
title: "Sign up",
description: "Open-source Experience Management. Free & open source.",
};
export const SignupPage = async () => {
const locale = await findMatchingLocale();
const isSSOEnabled = await getIsSSOEnabled();
const t = await getTranslations();
return (
<div className="flex flex-col items-center">
<h2 className="mb-6 text-xl font-medium">{t("setup.signup.create_administrator")}</h2>
<p className="text-sm text-slate-800">{t("setup.signup.this_user_has_all_the_power")}</p>
<hr className="my-6 w-full border-slate-200" />
<SignupForm
webAppUrl={WEBAPP_URL}
termsUrl={TERMS_URL}
privacyUrl={PRIVACY_URL}
emailVerificationDisabled={EMAIL_VERIFICATION_DISABLED}
emailAuthEnabled={EMAIL_AUTH_ENABLED}
googleOAuthEnabled={GOOGLE_OAUTH_ENABLED}
githubOAuthEnabled={GITHUB_OAUTH_ENABLED}
azureOAuthEnabled={AZURE_OAUTH_ENABLED}
oidcOAuthEnabled={OIDC_OAUTH_ENABLED}
oidcDisplayName={OIDC_DISPLAY_NAME}
userLocale={locale}
defaultOrganizationId={DEFAULT_ORGANIZATION_ID}
defaultOrganizationRole={DEFAULT_ORGANIZATION_ROLE}
isSSOEnabled={isSSOEnabled}
isTurnstileConfigured={IS_TURNSTILE_CONFIGURED}
/>
</div>
);
};

View File

@@ -0,0 +1,20 @@
import { FormbricksLogo } from "@/modules/ui/components/formbricks-logo";
import { Toaster } from "react-hot-toast";
export const SetupLayout = ({ children }: { children: React.ReactNode }) => {
return (
<>
<Toaster />
<div className="flex h-full w-full items-center justify-center bg-slate-50">
<div
style={{ scrollbarGutter: "stable both-edges" }}
className="flex max-h-[90vh] w-[40rem] flex-col items-center space-y-4 overflow-auto rounded-lg border bg-white p-12 text-center shadow-md">
<div className="h-20 w-20 rounded-lg bg-slate-900 p-2">
<FormbricksLogo className="h-full w-full" />
</div>
{children}
</div>
</div>
</>
);
};

View File

@@ -3,9 +3,9 @@
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { sendInviteMemberEmail } from "@/modules/email";
import { inviteUser } from "@/modules/setup/organization/[organizationId]/invite/lib/invite";
import { z } from "zod";
import { INVITE_DISABLED } from "@formbricks/lib/constants";
import { inviteUser } from "@formbricks/lib/invite/service";
import { ZId } from "@formbricks/types/common";
import { AuthenticationError } from "@formbricks/types/errors";
import { ZUserEmail, ZUserName } from "@formbricks/types/user";
@@ -34,19 +34,17 @@ export const inviteOrganizationMemberAction = authenticatedActionClient
],
});
const invite = await inviteUser({
const invitedUserId = await inviteUser({
organizationId: parsedInput.organizationId,
invitee: {
email: parsedInput.email,
name: parsedInput.name,
role: "owner",
teamIds: [],
},
currentUserId: ctx.user.id,
});
await sendInviteMemberEmail(
invite.id,
invitedUserId,
parsedInput.email,
ctx.user.name,
"",
@@ -55,5 +53,5 @@ export const inviteOrganizationMemberAction = authenticatedActionClient
ctx.user.locale
);
return invite;
return invitedUserId;
});

View File

@@ -1,7 +1,11 @@
"use client";
import { inviteOrganizationMemberAction } from "@/app/setup/organization/[organizationId]/invite/actions";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { inviteOrganizationMemberAction } from "@/modules/setup/organization/[organizationId]/invite/actions";
import {
type TInviteMembersFormSchema,
ZInviteMembersFormSchema,
} from "@/modules/setup/organization/[organizationId]/invite/types/invites";
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { FormControl, FormError, FormField, FormItem, FormProvider } from "@/modules/ui/components/form";
@@ -13,7 +17,6 @@ import { useRouter } from "next/navigation";
import React, { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { TInviteMembersFormSchema, ZInviteMembersFormSchema } from "@formbricks/types/invites";
interface InviteMembersProps {
IS_SMTP_CONFIGURED: boolean;

View File

@@ -0,0 +1,64 @@
import { inviteCache } from "@/lib/cache/invite";
import { TInvitee } from "@/modules/setup/organization/[organizationId]/invite/types/invites";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
export const inviteUser = async ({
invitee,
organizationId,
currentUserId,
}: {
organizationId: string;
invitee: TInvitee;
currentUserId: string;
}): Promise<string> => {
try {
const { name, email } = invitee;
const existingInvite = await prisma.invite.findFirst({ where: { email, organizationId } });
if (existingInvite) {
throw new InvalidInputError("Invite already exists");
}
const user = await prisma.user.findUnique({ where: { email } });
if (user) {
const member = await getMembershipByUserIdOrganizationId(user.id, organizationId);
if (member) {
throw new InvalidInputError("User is already a member of this organization");
}
}
const expiresIn = 7 * 24 * 60 * 60 * 1000; // 7 days
const expiresAt = new Date(Date.now() + expiresIn);
const invite = await prisma.invite.create({
data: {
email,
name,
organization: { connect: { id: organizationId } },
creator: { connect: { id: currentUserId } },
acceptor: user ? { connect: { id: user.id } } : undefined,
role: "owner",
expiresAt,
},
});
inviteCache.revalidate({
id: invite.id,
organizationId: invite.organizationId,
});
return invite.id;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};

View File

@@ -0,0 +1,35 @@
import { authOptions } from "@/modules/auth/lib/authOptions";
import { InviteMembers } from "@/modules/setup/organization/[organizationId]/invite/components/invite-members";
import { Metadata } from "next";
import { getServerSession } from "next-auth";
import { getTranslations } from "next-intl/server";
import { notFound } from "next/navigation";
import { SMTP_HOST, SMTP_PASSWORD, SMTP_PORT, SMTP_USER } from "@formbricks/lib/constants";
import { verifyUserRoleAccess } from "@formbricks/lib/organization/auth";
import { AuthenticationError } from "@formbricks/types/errors";
export const metadata: Metadata = {
title: "Invite",
description: "Open-source Experience Management. Free & open source.",
};
interface InvitePageProps {
params: Promise<{ organizationId: string }>;
}
export const InvitePage = async (props: InvitePageProps) => {
const params = await props.params;
const t = await getTranslations();
const IS_SMTP_CONFIGURED = Boolean(SMTP_HOST && SMTP_PORT && SMTP_USER && SMTP_PASSWORD);
const session = await getServerSession(authOptions);
if (!session) throw new AuthenticationError(t("common.session_not_found"));
const { hasCreateOrUpdateMembersAccess } = await verifyUserRoleAccess(
params.organizationId,
session.user.id
);
if (!hasCreateOrUpdateMembersAccess) return notFound();
return <InviteMembers IS_SMTP_CONFIGURED={IS_SMTP_CONFIGURED} organizationId={params.organizationId} />;
};

View File

@@ -0,0 +1,24 @@
import { z } from "zod";
import { ZInvite } from "@formbricks/database/zod/invites";
import { ZUserName } from "@formbricks/types/user";
export const ZInvitee = ZInvite.pick({
name: true,
email: true,
}).extend({
name: ZUserName,
});
export type TInvitee = z.infer<typeof ZInvitee>;
export const ZInviteMembersFormSchema = z.record(
ZInvite.pick({
email: true,
name: true,
}).extend({
email: z.string().email("Invalid email address"),
name: ZUserName,
})
);
export type TInviteMembersFormSchema = z.infer<typeof ZInviteMembersFormSchema>;

View File

@@ -0,0 +1,45 @@
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { RemovedFromOrganization } from "@/modules/setup/organization/create/components/removed-from-organization";
import { ClientLogout } from "@/modules/ui/components/client-logout";
import { Metadata } from "next";
import { getServerSession } from "next-auth";
import { getTranslations } from "next-intl/server";
import { notFound } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { gethasNoOrganizations } from "@formbricks/lib/instance/service";
import { getOrganizationsByUserId } from "@formbricks/lib/organization/service";
import { getUser } from "@formbricks/lib/user/service";
import { AuthenticationError } from "@formbricks/types/errors";
import { CreateOrganization } from "./components/create-organization";
export const metadata: Metadata = {
title: "Create Organization",
description: "Open-source Experience Management. Free & open source.",
};
export const CreateOrganizationPage = async () => {
const t = await getTranslations();
const session = await getServerSession(authOptions);
if (!session) throw new AuthenticationError(t("common.session_not_found"));
const user = await getUser(session.user.id);
if (!user) {
return <ClientLogout />;
}
const hasNoOrganizations = await gethasNoOrganizations();
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const userOrganizations = await getOrganizationsByUserId(session.user.id);
if (hasNoOrganizations || isMultiOrgEnabled) {
return <CreateOrganization />;
}
if (userOrganizations.length === 0) {
return <RemovedFromOrganization user={user} isFormbricksCloud={IS_FORMBRICKS_CLOUD} />;
}
return notFound();
};

View File

@@ -7,23 +7,21 @@ import { getOriginalFileNameFromUrl } from "@formbricks/lib/storage/utils";
interface FileUploadResponseProps {
selected: string[];
}
export const FileUploadResponse = ({ selected }: FileUploadResponseProps) => {
const t = useTranslations();
if (selected.length === 0) {
return <div className="font-semibold text-slate-500">{t("common.skipped")}</div>;
}
return (
<div className="">
{selected.map((fileUrl, index) => {
const fileName = getOriginalFileNameFromUrl(fileUrl);
return (
<a
href={fileUrl}
key={index}
download={fileName}
target="_blank"
rel="noopener noreferrer"
className="group flex max-w-60 items-center justify-center rounded-lg bg-slate-200 px-2 py-1 hover:bg-slate-300">
<p className="w-full overflow-hidden overflow-ellipsis whitespace-nowrap text-center text-slate-700 group-hover:text-slate-800">
{fileName ? fileName : "Download"}

View File

@@ -1,6 +1,6 @@
{
"name": "@formbricks/web",
"version": "3.1.3",
"version": "3.1.4",
"private": true,
"scripts": {
"clean": "rimraf .turbo node_modules .next",
@@ -10,10 +10,11 @@
"build:dev": "next build",
"start": "next start",
"lint": "next lint",
"test": "dotenv -e ../../.env -- vitest run"
"test": "dotenv -e ../../.env -- vitest run",
"test:coverage": "dotenv -e ../../.env -- vitest run --coverage"
},
"dependencies": {
"@ai-sdk/azure": "1.0.10",
"@ai-sdk/azure": "1.1.9",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/modifiers": "9.0.0",
"@dnd-kit/sortable": "10.0.0",
@@ -58,7 +59,7 @@
"@radix-ui/react-toggle-group": "1.1.1",
"@radix-ui/react-tooltip": "1.1.5",
"@react-email/components": "0.0.31",
"@sentry/nextjs": "8.45.1",
"@sentry/nextjs": "8.52.0",
"@tailwindcss/forms": "0.5.9",
"@tailwindcss/typography": "0.5.15",
"@tanstack/react-table": "8.20.6",
@@ -66,7 +67,7 @@
"@vercel/og": "0.6.4",
"@vercel/otel": "1.10.0",
"@vercel/speed-insights": "1.1.0",
"ai": "4.0.18",
"ai": "4.1.17",
"autoprefixer": "10.4.20",
"bcryptjs": "2.4.3",
"boring-avatars": "1.11.2",
@@ -130,6 +131,7 @@
"@types/nodemailer": "6.4.17",
"@types/papaparse": "5.3.15",
"@types/qrcode": "1.5.5",
"@vitest/coverage-v8": "2.1.8",
"vite": "6.0.9",
"vitest": "2.1.8",
"vitest-mock-extended": "2.0.2"

View File

@@ -138,6 +138,7 @@ test.describe("Create, update and delete team", async () => {
await page.getByRole("link", { name: "Organization" }).click();
await page.waitForURL(/\/environments\/[^/]+\/settings\/general/);
await page.waitForTimeout(2000);
await page.waitForLoadState("networkidle");
await expect(page.getByText("Teams")).toBeVisible();
await page.getByText("Teams").click();

View File

@@ -1,10 +1,18 @@
import tsconfigPaths from "vite-tsconfig-paths";
import { defineConfig } from "vitest/config";
// vitest.config.ts
import tsconfigPaths from 'vite-tsconfig-paths';
import { loadEnv } from 'vite'
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
exclude: ["playwright/**", "node_modules/**"],
setupFiles: ["../../packages/lib/vitestSetup.ts"],
exclude: ['playwright/**', 'node_modules/**'],
setupFiles: ['../../packages/lib/vitestSetup.ts'],
env: loadEnv('', process.cwd(), ''),
coverage: {
provider: 'v8', // Use V8 as the coverage provider
reporter: ['text', 'html', 'lcov'], // Generate text summary and HTML reports
reportsDirectory: './coverage', // Output coverage reports to the coverage/ directory
},
},
plugins: [tsconfigPaths()],
});
});

View File

@@ -14,9 +14,7 @@ export class AttributeAPI {
async update(
attributeUpdateInput: Omit<TAttributeUpdateInput, "environmentId">
): Promise<
Result<{ changed: boolean; message: string; details?: Record<string, string> }, ApiErrorResponse>
> {
): Promise<Result<{ changed: boolean; message: string; messages?: string[] }, ApiErrorResponse>> {
// transform all attributes to string if attributes are present into a new attributes copy
const attributes: Record<string, string> = {};
for (const key in attributeUpdateInput.attributes) {

View File

@@ -0,0 +1,18 @@
import { type Result } from "@formbricks/types/error-handlers";
import { type ApiErrorResponse } from "@formbricks/types/errors";
import { type TJsEnvironmentState } from "@formbricks/types/js";
import { makeRequest } from "../../utils/make-request";
export class EnvironmentAPI {
private apiHost: string;
private environmentId: string;
constructor(apiHost: string, environmentId: string) {
this.apiHost = apiHost;
this.environmentId = environmentId;
}
async getState(): Promise<Result<TJsEnvironmentState, ApiErrorResponse>> {
return makeRequest(this.apiHost, `/api/v1/client/${this.environmentId}/environment/`, "GET");
}
}

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