mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-04 10:30:00 -06:00
Merge branch 'main' into fix/discord-webhook-not-pinging
This commit is contained in:
39
.github/workflows/sonarqube.yml
vendored
39
.github/workflows/sonarqube.yml
vendored
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -18,6 +18,7 @@
|
||||
},
|
||||
"jsEngine": "hermes",
|
||||
"name": "react-native-demo",
|
||||
"newArchEnabled": true,
|
||||
"orientation": "portrait",
|
||||
"slug": "react-native-demo",
|
||||
"splash": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ProjectSettingsLayout } from "@/modules/projects/settings/layout";
|
||||
import { ProjectSettingsLayout, metadata } from "@/modules/projects/settings/layout";
|
||||
|
||||
export { metadata };
|
||||
export default ProjectSettingsLayout;
|
||||
|
||||
@@ -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&", "?")
|
||||
.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);
|
||||
});
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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, "");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { LoginPage } from "@/modules/auth/login/page";
|
||||
import { LoginPage, metadata } from "@/modules/auth/login/page";
|
||||
|
||||
export { metadata };
|
||||
export default LoginPage;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
|
||||
3
apps/web/app/api/v1/client/[environmentId]/user/route.ts
Normal file
3
apps/web/app/api/v1/client/[environmentId]/user/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { OPTIONS, POST } from "@/modules/ee/contacts/api/client/[environmentId]/user/route";
|
||||
|
||||
export { POST, OPTIONS };
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
78
apps/web/modules/auth/invite/lib/invite.ts
Normal file
78
apps/web/modules/auth/invite/lib/invite.ts
Normal 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)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -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);
|
||||
147
apps/web/modules/auth/invite/page.tsx
Normal file
147
apps/web/modules/auth/invite/page.tsx
Normal 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")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
11
apps/web/modules/auth/invite/types/invites.ts
Normal file
11
apps/web/modules/auth/invite/types/invites.ts
Normal 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"> {}
|
||||
@@ -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
|
||||
|
||||
78
apps/web/modules/auth/signup/lib/invite.ts
Normal file
78
apps/web/modules/auth/signup/lib/invite.ts
Normal 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)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
65
apps/web/modules/auth/signup/lib/team.ts
Normal file
65
apps/web/modules/auth/signup/lib/team.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
7
apps/web/modules/auth/signup/types/invites.ts
Normal file
7
apps/web/modules/auth/signup/types/invites.ts
Normal 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"> {}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -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),
|
||||
],
|
||||
}
|
||||
)();
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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),
|
||||
],
|
||||
}
|
||||
)();
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
31
apps/web/modules/ee/role-management/lib/invite.ts
Normal file
31
apps/web/modules/ee/role-management/lib/invite.ts
Normal 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
|
||||
}
|
||||
}
|
||||
};
|
||||
8
apps/web/modules/ee/role-management/types/invites.ts
Normal file
8
apps/web/modules/ee/role-management/types/invites.ts
Normal 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>;
|
||||
@@ -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 || ""
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -93,7 +93,7 @@ export function ResponseFinishedEmail({
|
||||
const questions = getQuestionResponseMapping(survey, response);
|
||||
|
||||
return (
|
||||
<EmailTemplate>
|
||||
<EmailTemplate locale={locale}>
|
||||
<Container>
|
||||
<Row>
|
||||
<Column>
|
||||
|
||||
@@ -24,7 +24,7 @@ export function WeeklySummaryNotificationEmail({
|
||||
locale,
|
||||
}: WeeklySummaryNotificationEmailProps): React.JSX.Element {
|
||||
return (
|
||||
<EmailTemplate>
|
||||
<EmailTemplate locale={locale}>
|
||||
<NotificationHeader
|
||||
endDate={endDate}
|
||||
endYear={endYear}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
33
apps/web/modules/setup/(fresh-instance)/intro/page.tsx
Normal file
33
apps/web/modules/setup/(fresh-instance)/intro/page.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
14
apps/web/modules/setup/(fresh-instance)/layout.tsx
Normal file
14
apps/web/modules/setup/(fresh-instance)/layout.tsx
Normal 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}</>;
|
||||
};
|
||||
55
apps/web/modules/setup/(fresh-instance)/signup/page.tsx
Normal file
55
apps/web/modules/setup/(fresh-instance)/signup/page.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
20
apps/web/modules/setup/layout.tsx
Normal file
20
apps/web/modules/setup/layout.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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} />;
|
||||
};
|
||||
@@ -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>;
|
||||
45
apps/web/modules/setup/organization/create/page.tsx
Normal file
45
apps/web/modules/setup/organization/create/page.tsx
Normal 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();
|
||||
};
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()],
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
18
packages/api/src/api/client/environment.ts
Normal file
18
packages/api/src/api/client/environment.ts
Normal 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
Reference in New Issue
Block a user