mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-29 18:00:26 -06:00
Compare commits
43 Commits
feature/re
...
fix-hidden
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c54a48e70b | ||
|
|
884b6f12ae | ||
|
|
5cae0febc9 | ||
|
|
0e898db710 | ||
|
|
40d54d60d4 | ||
|
|
269e026381 | ||
|
|
8245f2f6af | ||
|
|
8c07e8b1a8 | ||
|
|
e94b0845a2 | ||
|
|
4acc85bd12 | ||
|
|
ffa534d5eb | ||
|
|
fccf0f1e39 | ||
|
|
a5d80d1f02 | ||
|
|
803a73afb6 | ||
|
|
1eb8049d04 | ||
|
|
f9ed0c487f | ||
|
|
fa7d33351f | ||
|
|
e3084760b8 | ||
|
|
8e5addad5c | ||
|
|
6e741018e5 | ||
|
|
98c7c78421 | ||
|
|
16c588138c | ||
|
|
1373863af5 | ||
|
|
75315ea2c5 | ||
|
|
9f6fb8a387 | ||
|
|
b84d3d5806 | ||
|
|
5c2c1bbfcd | ||
|
|
54e84858b5 | ||
|
|
833d0789d7 | ||
|
|
1a974f3dd8 | ||
|
|
146173883f | ||
|
|
ebb02a5723 | ||
|
|
c96f7fed18 | ||
|
|
861eff3cd2 | ||
|
|
b66c0d17d0 | ||
|
|
0e748050f3 | ||
|
|
ae3524b79f | ||
|
|
0ce58b592a | ||
|
|
578346840e | ||
|
|
56bcb46d6c | ||
|
|
91405c48e0 | ||
|
|
b40dff621a | ||
|
|
7d4409e2b4 |
@@ -1,39 +1,56 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
# **/node_modules
|
||||
**/node_modules
|
||||
.pnp
|
||||
.pnp.js
|
||||
.pnpm-store/
|
||||
|
||||
# testing
|
||||
coverage
|
||||
**/coverage
|
||||
|
||||
# next.js
|
||||
**/.next
|
||||
**/out
|
||||
**/.next/
|
||||
**/out/
|
||||
**/build
|
||||
|
||||
# node
|
||||
**/dist
|
||||
**/dist/
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
*.pem
|
||||
Zone.Identifier
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# turbo
|
||||
.turbo
|
||||
# local env files
|
||||
**/.env
|
||||
**/.env.local
|
||||
**/.env.development.local
|
||||
**/.env.test.local
|
||||
**/.env.production.local
|
||||
!packages/database/.env
|
||||
!apps/web/.env
|
||||
|
||||
# nixos stuff
|
||||
# build tools
|
||||
.turbo
|
||||
**/*vite.config.*.timestamp-*
|
||||
|
||||
# environment specific
|
||||
.direnv
|
||||
|
||||
.vscode
|
||||
.github
|
||||
**/.turbo
|
||||
# Playwright
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
|
||||
.env
|
||||
# project specific
|
||||
packages/lib/uploads
|
||||
apps/web/public/js
|
||||
packages/database/migrations
|
||||
branch.json
|
||||
@@ -130,6 +130,9 @@ AZUREAD_TENANT_ID=
|
||||
# OIDC_DISPLAY_NAME=
|
||||
# OIDC_SIGNING_ALGORITHM=
|
||||
|
||||
# Configure SAML SSO
|
||||
# SAML_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/formbricks-saml
|
||||
|
||||
# Configure this when you want to ship JS & CSS files from a complete URL instead of the current domain
|
||||
# ASSET_PREFIX_URL=
|
||||
|
||||
|
||||
@@ -31,16 +31,15 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Depot CLI
|
||||
uses: depot/setup-action@v1
|
||||
|
||||
# Install the cosign tool except on PR
|
||||
# https://github.com/sigstore/cosign-installer
|
||||
- name: Install cosign
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: sigstore/cosign-installer@v3.5.0
|
||||
|
||||
# Set up Docker Buildx
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# Login against a Docker registry except on PR
|
||||
# https://github.com/docker/login-action
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
@@ -63,8 +62,10 @@ jobs:
|
||||
# https://github.com/docker/build-push-action
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v5
|
||||
uses: depot/build-push-action@v1
|
||||
with:
|
||||
project: tw0fqmsx3c
|
||||
token: ${{ secrets.DEPOT_PROJECT_TOKEN }}
|
||||
context: .
|
||||
file: ./apps/web/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
11
.github/workflows/release-docker-github.yml
vendored
11
.github/workflows/release-docker-github.yml
vendored
@@ -34,16 +34,15 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Depot CLI
|
||||
uses: depot/setup-action@v1
|
||||
|
||||
# Install the cosign tool except on PR
|
||||
# https://github.com/sigstore/cosign-installer
|
||||
- name: Install cosign
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: sigstore/cosign-installer@v3.5.0
|
||||
|
||||
# Set up Docker Buildx
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# Login against a Docker registry except on PR
|
||||
# https://github.com/docker/login-action
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
@@ -66,8 +65,10 @@ jobs:
|
||||
# https://github.com/docker/build-push-action
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v5
|
||||
uses: depot/build-push-action@v1
|
||||
with:
|
||||
project: tw0fqmsx3c
|
||||
token: ${{ secrets.DEPOT_PROJECT_TOKEN }}
|
||||
context: .
|
||||
file: ./apps/web/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
1
.github/workflows/sonarqube.yml
vendored
1
.github/workflows/sonarqube.yml
vendored
@@ -6,6 +6,7 @@ on:
|
||||
- main
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
merge_group:
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
|
||||
52
.github/workflows/tolgee.yml
vendored
52
.github/workflows/tolgee.yml
vendored
@@ -3,7 +3,8 @@ permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches:
|
||||
- main
|
||||
|
||||
@@ -11,10 +12,30 @@ jobs:
|
||||
tag-production-keys:
|
||||
name: Tag Production Keys
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.pull_request.merged == true
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # This ensures we get the full git history
|
||||
|
||||
- name: Get source branch name
|
||||
id: branch-name
|
||||
run: |
|
||||
# For PR merges, use the head ref from the pull request event
|
||||
SOURCE_BRANCH="${{ github.head_ref }}"
|
||||
|
||||
# Only remove username prefix if needed
|
||||
if [[ "$SOURCE_BRANCH" =~ ^[a-zA-Z0-9][a-zA-Z0-9-]+/ ]]; then
|
||||
PREFIX=${SOURCE_BRANCH%%/*}
|
||||
if [[ ! "$PREFIX" =~ ^(feature|fix|bugfix|hotfix|release|chore|docs|test|refactor|style|perf|build|ci|revert)$ ]]; then
|
||||
SOURCE_BRANCH=${SOURCE_BRANCH#*/}
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "SOURCE_BRANCH=$SOURCE_BRANCH" >> $GITHUB_ENV
|
||||
echo "Detected source branch: $SOURCE_BRANCH"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
@@ -26,17 +47,38 @@ jobs:
|
||||
|
||||
- name: Tag Production Keys
|
||||
run: |
|
||||
BRANCH_NAME=${GITHUB_REF##*/}
|
||||
npx tolgee tag \
|
||||
--api-key ${{ secrets.TOLGEE_API_KEY }} \
|
||||
--filter-extracted \
|
||||
--filter-tag "draft: ${BRANCH_NAME}" \
|
||||
--filter-tag "draft:${SOURCE_BRANCH}" \
|
||||
--tag production \
|
||||
--untag "draft: ${BRANCH_NAME}"
|
||||
--untag "draft:${SOURCE_BRANCH}"
|
||||
|
||||
- name: Tag Deprecated Keys
|
||||
- name: Tag unused production keys as Deprecated
|
||||
run: |
|
||||
npx tolgee tag \
|
||||
--api-key ${{ secrets.TOLGEE_API_KEY }} \
|
||||
--filter-not-extracted --filter-tag production \
|
||||
--tag deprecated --untag production
|
||||
|
||||
- name: Tag unused draft:current-branch keys as Deprecated
|
||||
run: |
|
||||
npx tolgee tag \
|
||||
--api-key ${{ secrets.TOLGEE_API_KEY }} \
|
||||
--filter-not-extracted --filter-tag "draft:${SOURCE_BRANCH}" \
|
||||
--tag deprecated --untag "draft:${SOURCE_BRANCH}"
|
||||
|
||||
- name: Sync with backup
|
||||
run: |
|
||||
npx tolgee sync \
|
||||
--api-key ${{ secrets.TOLGEE_API_KEY }} \
|
||||
--backup ./tolgee-backup \
|
||||
--continue-on-warning \
|
||||
--yes
|
||||
|
||||
- name: Upload backup as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: tolgee-backup-${{ github.sha }}
|
||||
path: ./tolgee-backup
|
||||
retention-days: 90
|
||||
|
||||
44
.gitignore
vendored
44
.gitignore
vendored
@@ -1,25 +1,26 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
node_modules
|
||||
**/node_modules
|
||||
.pnp
|
||||
.pnp.js
|
||||
.pnpm-store/
|
||||
|
||||
# testing
|
||||
coverage
|
||||
**/coverage
|
||||
|
||||
# next.js
|
||||
.next/
|
||||
out/
|
||||
build
|
||||
**/.next/
|
||||
**/out/
|
||||
**/build
|
||||
|
||||
# node
|
||||
dist/
|
||||
**/dist/
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
*.pem
|
||||
Zone.Identifier
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
@@ -27,36 +28,29 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
**/.env
|
||||
**/.env.local
|
||||
**/.env.development.local
|
||||
**/.env.test.local
|
||||
**/.env.production.local
|
||||
!packages/database/.env
|
||||
!apps/web/.env
|
||||
|
||||
# turbo
|
||||
# build tools
|
||||
.turbo
|
||||
**/*vite.config.*.timestamp-*
|
||||
|
||||
# nixos stuff
|
||||
# environment specific
|
||||
.direnv
|
||||
|
||||
Zone.Identifier
|
||||
|
||||
# Playwright
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
|
||||
# uploads
|
||||
# project specific
|
||||
packages/lib/uploads
|
||||
|
||||
# Vite Timestamps
|
||||
*vite.config.*.timestamp-*
|
||||
|
||||
# js compiled assets
|
||||
apps/web/public/js
|
||||
|
||||
|
||||
packages/database/migrations
|
||||
packages/database/migrations
|
||||
branch.json
|
||||
@@ -1 +1,2 @@
|
||||
echo "{\"branchName\": \"$(git rev-parse --abbrev-ref HEAD)\"}" > ../branch.json
|
||||
echo "{\"branchName\": \"$(git rev-parse --abbrev-ref HEAD)\"}" > ./branch.json
|
||||
prettier --write ./branch.json
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
echo "{\"branchName\": \"$(git rev-parse --abbrev-ref HEAD)\"}" > ../branch.json
|
||||
@@ -1,5 +1,21 @@
|
||||
pnpm lint-staged
|
||||
pnpm tolgee-pull || true
|
||||
echo "{\"branchName\": \"main\"}" > ../branch.json
|
||||
git add branch.json packages/lib/messages/*.json
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
# Load environment variables from .env files
|
||||
if [ -f .env ]; then
|
||||
set -a
|
||||
. .env
|
||||
set +a
|
||||
fi
|
||||
|
||||
pnpm lint-staged
|
||||
|
||||
# Run tolgee-pull if branch.json exists and NEXT_PUBLIC_TOLGEE_API_KEY is not set
|
||||
if [ -f branch.json ]; then
|
||||
if [ -z "$NEXT_PUBLIC_TOLGEE_API_KEY" ]; then
|
||||
echo "Skipping tolgee-pull: NEXT_PUBLIC_TOLGEE_API_KEY is not set"
|
||||
else
|
||||
pnpm run tolgee-pull
|
||||
git add packages/lib/messages
|
||||
fi
|
||||
fi
|
||||
@@ -13,7 +13,7 @@
|
||||
<h3 align="center">Formbricks</h3>
|
||||
|
||||
<p align="center">
|
||||
Harvest user-insights, build irresistible experiences.
|
||||
The Open Source Qualtrics Alternative
|
||||
<br />
|
||||
<a href="https://formbricks.com/">Website</a>
|
||||
</p>
|
||||
|
||||
3
apps/web/.gitignore
vendored
3
apps/web/.gitignore
vendored
@@ -48,3 +48,6 @@ uploads/
|
||||
|
||||
# Sentry Config File
|
||||
.sentryclirc
|
||||
|
||||
# SAML Preloaded Connections
|
||||
saml-connection/
|
||||
@@ -33,6 +33,9 @@ ENV CRON_SECRET="placeholder_for_cron_secret_of_64_chars_get_overwritten_at_runt
|
||||
ARG NEXT_PUBLIC_SENTRY_DSN
|
||||
ARG SENTRY_AUTH_TOKEN
|
||||
|
||||
# Increase Node.js memory limit
|
||||
# ENV NODE_OPTIONS="--max_old_space_size=4096"
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /app
|
||||
|
||||
@@ -41,19 +44,17 @@ WORKDIR /app
|
||||
# COPY --from=builder /app/out/json/ .
|
||||
# COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
|
||||
# Install the dependencies
|
||||
# RUN pnpm install
|
||||
|
||||
# Prepare the build
|
||||
COPY . .
|
||||
|
||||
# Create a .env file
|
||||
RUN touch apps/web/.env
|
||||
|
||||
# Install the dependencies
|
||||
RUN pnpm install
|
||||
|
||||
# Build the project
|
||||
# RUN pnpm post-install --filter=@formbricks/web...
|
||||
RUN pnpm build --filter=@formbricks/web...
|
||||
RUN NODE_OPTIONS="--max_old_space_size=4096" pnpm build --filter=@formbricks/web...
|
||||
|
||||
# Extract Prisma version
|
||||
RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_version.txt
|
||||
@@ -76,6 +77,7 @@ WORKDIR /home/nextjs
|
||||
COPY --from=installer /app/apps/web/next.config.mjs .
|
||||
COPY --from=installer /app/apps/web/package.json .
|
||||
# Leverage output traces to reduce image size
|
||||
|
||||
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/.next/standalone ./
|
||||
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/.next/static ./apps/web/.next/static
|
||||
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/public ./apps/web/public
|
||||
@@ -105,6 +107,11 @@ ENV HOSTNAME "0.0.0.0"
|
||||
RUN mkdir -p /home/nextjs/apps/web/uploads/
|
||||
VOLUME /home/nextjs/apps/web/uploads/
|
||||
|
||||
# Prepare volume for SAML preloaded connection
|
||||
RUN mkdir -p /home/nextjs/apps/web/saml-connection
|
||||
VOLUME /home/nextjs/apps/web/saml-connection
|
||||
|
||||
CMD supercronic -quiet /app/docker/cronjobs & \
|
||||
(cd packages/database && npm run db:migrate:deploy) && \
|
||||
(cd packages/database && npm run db:create-saml-database:deploy) && \
|
||||
exec node apps/web/server.js
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
UNSUPPORTED_TYPES_BY_NOTION,
|
||||
} from "@/app/(app)/environments/[environmentId]/integrations/notion/constants";
|
||||
import NotionLogo from "@/images/notion.png";
|
||||
import { getQuestionTypes } from "@/modules/survey/lib/questions";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
@@ -19,7 +20,6 @@ import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
|
||||
import { getQuestionTypes } from "@formbricks/lib/utils/questions";
|
||||
import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall";
|
||||
import { TIntegrationInput } from "@formbricks/types/integration";
|
||||
import {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { RenderResponse } from "@/modules/analysis/components/SingleResponseCard/components/RenderResponse";
|
||||
import { VARIABLES_ICON_MAP, getQuestionIconMap } from "@/modules/survey/lib/questions";
|
||||
import { getSelectionColumn } from "@/modules/ui/components/data-table";
|
||||
import { ResponseBadges } from "@/modules/ui/components/response-badges";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
@@ -12,7 +13,6 @@ import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { processResponseData } from "@formbricks/lib/responses";
|
||||
import { getContactIdentifier } from "@formbricks/lib/utils/contact";
|
||||
import { getFormattedDateTimeString } from "@formbricks/lib/utils/datetime";
|
||||
import { VARIABLES_ICON_MAP, getQuestionIconMap } from "@formbricks/lib/utils/questions";
|
||||
import { recallToHeadline } from "@formbricks/lib/utils/recall";
|
||||
import { TResponseTableData } from "@formbricks/types/responses";
|
||||
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
|
||||
@@ -51,9 +51,9 @@ export const HiddenFieldsSummary = ({ environment, questionSummary, locale }: Hi
|
||||
<div className="col-span-2 pl-4 md:pl-6">{t("common.response")}</div>
|
||||
<div className="px-4 md:px-6">{t("common.time")}</div>
|
||||
</div>
|
||||
{questionSummary.samples.slice(0, visibleResponses).map((response) => (
|
||||
{questionSummary.samples.slice(0, visibleResponses).map((response, idx) => (
|
||||
<div
|
||||
key={response.value}
|
||||
key={`${response.value}-${idx}`}
|
||||
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
|
||||
<div className="pl-4 md:pl-6">
|
||||
{response.contact ? (
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { getQuestionTypes } from "@/modules/survey/lib/questions";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { InboxIcon } from "lucide-react";
|
||||
import type { JSX } from "react";
|
||||
import { getQuestionTypes } from "@formbricks/lib/utils/questions";
|
||||
import { recallToHeadline } from "@formbricks/lib/utils/recall";
|
||||
import { TSurvey, TSurveyQuestionSummary } from "@formbricks/types/surveys/types";
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { getQuestionIcon } from "@/modules/survey/lib/questions";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { TimerIcon } from "lucide-react";
|
||||
import { JSX } from "react";
|
||||
import { getQuestionIcon } from "@formbricks/lib/utils/questions";
|
||||
import { recallToHeadline } from "@formbricks/lib/utils/recall";
|
||||
import { TSurvey, TSurveyQuestionType, TSurveySummary } from "@formbricks/types/surveys/types";
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import clsx from "clsx";
|
||||
import { ChevronDown, ChevronUp, X } from "lucide-react";
|
||||
@@ -48,6 +49,7 @@ export const QuestionFilterComboBox = ({
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [openFilterValue, setOpenFilterValue] = React.useState<boolean>(false);
|
||||
const commandRef = React.useRef(null);
|
||||
const [searchQuery, setSearchQuery] = React.useState<string>("");
|
||||
const defaultLanguageCode = "default";
|
||||
useClickOutside(commandRef, () => setOpen(false));
|
||||
const { t } = useTranslate();
|
||||
@@ -73,6 +75,12 @@ export const QuestionFilterComboBox = ({
|
||||
(type === TSurveyQuestionTypeEnum.NPS || type === TSurveyQuestionTypeEnum.Rating) &&
|
||||
(filterValue === "Submitted" || filterValue === "Skipped");
|
||||
|
||||
const filteredOptions = options?.filter((o) =>
|
||||
(typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o)
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="inline-flex w-full flex-row">
|
||||
{filterOptions && filterOptions?.length <= 1 ? (
|
||||
@@ -160,10 +168,21 @@ export const QuestionFilterComboBox = ({
|
||||
{open && (
|
||||
<div className="animate-in bg-popover absolute top-0 z-10 max-h-52 w-full overflow-auto rounded-md bg-white outline-none">
|
||||
<CommandList>
|
||||
<div className="p-2">
|
||||
<Input
|
||||
type="text"
|
||||
autoFocus
|
||||
placeholder={t("common.search") + "..."}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full rounded-md border border-slate-300 p-2 text-sm focus:border-slate-300"
|
||||
/>
|
||||
</div>
|
||||
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options?.map((o) => (
|
||||
{filteredOptions?.map((o, index) => (
|
||||
<CommandItem
|
||||
key={`option-${typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}-${index}`}
|
||||
onSelect={() => {
|
||||
!isMultiple
|
||||
? onChangeFilterComboBoxValue(
|
||||
|
||||
3
apps/web/app/api/auth/saml/authorize/route.ts
Normal file
3
apps/web/app/api/auth/saml/authorize/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { GET } from "@/modules/ee/auth/saml/api/authorize/route";
|
||||
|
||||
export { GET };
|
||||
3
apps/web/app/api/auth/saml/callback/route.ts
Normal file
3
apps/web/app/api/auth/saml/callback/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { POST } from "@/modules/ee/auth/saml/api/callback/route";
|
||||
|
||||
export { POST };
|
||||
3
apps/web/app/api/auth/saml/token/route.ts
Normal file
3
apps/web/app/api/auth/saml/token/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { POST } from "@/modules/ee/auth/saml/api/token/route";
|
||||
|
||||
export { POST };
|
||||
3
apps/web/app/api/auth/saml/userinfo/route.ts
Normal file
3
apps/web/app/api/auth/saml/userinfo/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { GET } from "@/modules/ee/auth/saml/api/userinfo/route";
|
||||
|
||||
export { GET };
|
||||
@@ -61,6 +61,7 @@ export const getSurveysForEnvironmentState = reactCache(
|
||||
displayLimit: true,
|
||||
displayOption: true,
|
||||
hiddenFields: true,
|
||||
isBackButtonHidden: true,
|
||||
triggers: {
|
||||
select: {
|
||||
actionClass: {
|
||||
@@ -72,6 +73,7 @@ export const getSurveysForEnvironmentState = reactCache(
|
||||
},
|
||||
displayPercentage: true,
|
||||
delay: true,
|
||||
projectOverwrites: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { XCircleIcon } from "lucide-react";
|
||||
|
||||
const Error = ({ error }: { error: Error & { digest?: string } }) => {
|
||||
const { t } = useTranslate();
|
||||
return (
|
||||
<div className="mx-auto flex h-full max-w-xl flex-col items-center justify-center text-center">
|
||||
<XCircleIcon height={40} color="red" />
|
||||
<p className="text-md mt-4 font-bold text-zinc-900">{t("health.degraded")}</p>
|
||||
<p className="text-sm text-zinc-900">{error.message}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Error;
|
||||
@@ -1,54 +0,0 @@
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import { BadgeCheckIcon } from "lucide-react";
|
||||
import { Metadata } from "next";
|
||||
import { prisma } from "@formbricks/database";
|
||||
|
||||
export const dynamic = "force-dynamic"; // no caching
|
||||
|
||||
export const metadata: Metadata = {
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
googleBot: {
|
||||
index: false,
|
||||
follow: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const checkDatabaseConnection = async () => {
|
||||
try {
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
} catch (e) {
|
||||
console.error("Database connection error:", e);
|
||||
throw new Error("Database could not be reached");
|
||||
}
|
||||
};
|
||||
|
||||
/* const checkS3Connection = async () => {
|
||||
if (!IS_S3_CONFIGURED) {
|
||||
// dont try connecting if not in use
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await testS3BucketAccess();
|
||||
} catch (e) {
|
||||
throw new Error("S3 Bucket cannot be accessed");
|
||||
}
|
||||
}; */
|
||||
|
||||
const Page = async () => {
|
||||
const t = await getTranslate();
|
||||
await checkDatabaseConnection();
|
||||
// Skipping S3 check for now until it's fixed
|
||||
// await checkS3Connection();
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-full max-w-xl flex-col items-center justify-center text-center">
|
||||
<BadgeCheckIcon height={40} color="green" />
|
||||
<p className="text-md mt-4 font-bold text-zinc-900">{t("health.healthy")}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
3
apps/web/app/health/route.ts
Normal file
3
apps/web/app/health/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export async function GET() {
|
||||
return Response.json({ status: "ok" });
|
||||
}
|
||||
@@ -7064,5 +7064,6 @@ export const previewSurvey = (projectName: string, t: TFnType) => {
|
||||
triggers: [],
|
||||
showLanguageSwitch: false,
|
||||
followUps: [],
|
||||
isBackButtonHidden: false,
|
||||
} as TSurvey;
|
||||
};
|
||||
|
||||
@@ -39,7 +39,10 @@ interface LoginFormProps {
|
||||
oidcOAuthEnabled: boolean;
|
||||
oidcDisplayName?: string;
|
||||
isMultiOrgEnabled: boolean;
|
||||
isSSOEnabled: boolean;
|
||||
isSsoEnabled: boolean;
|
||||
samlSsoEnabled: boolean;
|
||||
samlTenant: string;
|
||||
samlProduct: string;
|
||||
}
|
||||
|
||||
export const LoginForm = ({
|
||||
@@ -52,7 +55,10 @@ export const LoginForm = ({
|
||||
oidcOAuthEnabled,
|
||||
oidcDisplayName,
|
||||
isMultiOrgEnabled,
|
||||
isSSOEnabled,
|
||||
isSsoEnabled,
|
||||
samlSsoEnabled,
|
||||
samlTenant,
|
||||
samlProduct,
|
||||
}: LoginFormProps) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -239,13 +245,16 @@ export const LoginForm = ({
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
{isSSOEnabled && (
|
||||
{isSsoEnabled && (
|
||||
<SSOOptions
|
||||
googleOAuthEnabled={googleOAuthEnabled}
|
||||
githubOAuthEnabled={githubOAuthEnabled}
|
||||
azureOAuthEnabled={azureOAuthEnabled}
|
||||
oidcOAuthEnabled={oidcOAuthEnabled}
|
||||
oidcDisplayName={oidcDisplayName}
|
||||
samlSsoEnabled={samlSsoEnabled}
|
||||
samlTenant={samlTenant}
|
||||
samlProduct={samlProduct}
|
||||
callbackUrl={callbackUrl}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { FormWrapper } from "@/modules/auth/components/form-wrapper";
|
||||
import { Testimonial } from "@/modules/auth/components/testimonial";
|
||||
import { getIsMultiOrgEnabled, getIsSSOEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import {
|
||||
getIsMultiOrgEnabled,
|
||||
getIsSamlSsoEnabled,
|
||||
getisSsoEnabled,
|
||||
} from "@/modules/ee/license-check/lib/utils";
|
||||
import { Metadata } from "next";
|
||||
import {
|
||||
AZURE_OAUTH_ENABLED,
|
||||
@@ -10,6 +14,9 @@ import {
|
||||
OIDC_DISPLAY_NAME,
|
||||
OIDC_OAUTH_ENABLED,
|
||||
PASSWORD_RESET_DISABLED,
|
||||
SAML_OAUTH_ENABLED,
|
||||
SAML_PRODUCT,
|
||||
SAML_TENANT,
|
||||
SIGNUP_ENABLED,
|
||||
} from "@formbricks/lib/constants";
|
||||
import { LoginForm } from "./components/login-form";
|
||||
@@ -20,7 +27,13 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
export const LoginPage = async () => {
|
||||
const [isMultiOrgEnabled, isSSOEnabled] = await Promise.all([getIsMultiOrgEnabled(), getIsSSOEnabled()]);
|
||||
const [isMultiOrgEnabled, isSsoEnabled, isSamlSsoEnabled] = await Promise.all([
|
||||
getIsMultiOrgEnabled(),
|
||||
getisSsoEnabled(),
|
||||
getIsSamlSsoEnabled(),
|
||||
]);
|
||||
|
||||
const samlSsoEnabled = isSamlSsoEnabled && SAML_OAUTH_ENABLED;
|
||||
return (
|
||||
<div className="grid min-h-screen w-full bg-gradient-to-tr from-slate-100 to-slate-50 lg:grid-cols-5">
|
||||
<div className="col-span-2 hidden lg:flex">
|
||||
@@ -38,7 +51,10 @@ export const LoginPage = async () => {
|
||||
oidcOAuthEnabled={OIDC_OAUTH_ENABLED}
|
||||
oidcDisplayName={OIDC_DISPLAY_NAME}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
isSSOEnabled={isSSOEnabled}
|
||||
isSsoEnabled={isSsoEnabled}
|
||||
samlSsoEnabled={samlSsoEnabled}
|
||||
samlTenant={SAML_TENANT}
|
||||
samlProduct={SAML_PRODUCT}
|
||||
/>
|
||||
</FormWrapper>
|
||||
</div>
|
||||
|
||||
@@ -53,8 +53,11 @@ interface SignupFormProps {
|
||||
emailVerificationDisabled: boolean;
|
||||
defaultOrganizationId?: string;
|
||||
defaultOrganizationRole?: TOrganizationRole;
|
||||
isSSOEnabled: boolean;
|
||||
isSsoEnabled: boolean;
|
||||
samlSsoEnabled: boolean;
|
||||
isTurnstileConfigured: boolean;
|
||||
samlTenant: string;
|
||||
samlProduct: string;
|
||||
}
|
||||
|
||||
export const SignupForm = ({
|
||||
@@ -72,8 +75,11 @@ export const SignupForm = ({
|
||||
emailVerificationDisabled,
|
||||
defaultOrganizationId,
|
||||
defaultOrganizationRole,
|
||||
isSSOEnabled,
|
||||
isSsoEnabled,
|
||||
samlSsoEnabled,
|
||||
isTurnstileConfigured,
|
||||
samlTenant,
|
||||
samlProduct,
|
||||
}: SignupFormProps) => {
|
||||
const [showLogin, setShowLogin] = useState(false);
|
||||
const searchParams = useSearchParams();
|
||||
@@ -266,13 +272,16 @@ export const SignupForm = ({
|
||||
</form>
|
||||
</FormProvider>
|
||||
)}
|
||||
{isSSOEnabled && (
|
||||
{isSsoEnabled && (
|
||||
<SSOOptions
|
||||
googleOAuthEnabled={googleOAuthEnabled}
|
||||
githubOAuthEnabled={githubOAuthEnabled}
|
||||
azureOAuthEnabled={azureOAuthEnabled}
|
||||
oidcOAuthEnabled={oidcOAuthEnabled}
|
||||
oidcDisplayName={oidcDisplayName}
|
||||
samlSsoEnabled={samlSsoEnabled}
|
||||
samlTenant={samlTenant}
|
||||
samlProduct={samlProduct}
|
||||
callbackUrl={callbackUrl}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { FormWrapper } from "@/modules/auth/components/form-wrapper";
|
||||
import { Testimonial } from "@/modules/auth/components/testimonial";
|
||||
import { getIsMultiOrgEnabled, getIsSSOEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import {
|
||||
getIsMultiOrgEnabled,
|
||||
getIsSamlSsoEnabled,
|
||||
getisSsoEnabled,
|
||||
} from "@/modules/ee/license-check/lib/utils";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
AZURE_OAUTH_ENABLED,
|
||||
@@ -14,6 +18,9 @@ import {
|
||||
OIDC_DISPLAY_NAME,
|
||||
OIDC_OAUTH_ENABLED,
|
||||
PRIVACY_URL,
|
||||
SAML_OAUTH_ENABLED,
|
||||
SAML_PRODUCT,
|
||||
SAML_TENANT,
|
||||
SIGNUP_ENABLED,
|
||||
TERMS_URL,
|
||||
WEBAPP_URL,
|
||||
@@ -24,7 +31,14 @@ import { SignupForm } from "./components/signup-form";
|
||||
export const SignupPage = async ({ searchParams: searchParamsProps }) => {
|
||||
const searchParams = await searchParamsProps;
|
||||
const inviteToken = searchParams["inviteToken"] ?? null;
|
||||
const [isMultOrgEnabled, isSSOEnabled] = await Promise.all([getIsMultiOrgEnabled(), getIsSSOEnabled()]);
|
||||
const [isMultOrgEnabled, isSsoEnabled, isSamlSsoEnabled] = await Promise.all([
|
||||
getIsMultiOrgEnabled(),
|
||||
getisSsoEnabled(),
|
||||
getIsSamlSsoEnabled(),
|
||||
]);
|
||||
|
||||
const samlSsoEnabled = isSamlSsoEnabled && SAML_OAUTH_ENABLED;
|
||||
|
||||
const locale = await findMatchingLocale();
|
||||
if (!inviteToken && (!SIGNUP_ENABLED || !isMultOrgEnabled)) {
|
||||
notFound();
|
||||
@@ -53,8 +67,11 @@ export const SignupPage = async ({ searchParams: searchParamsProps }) => {
|
||||
emailFromSearchParams={emailFromSearchParams}
|
||||
defaultOrganizationId={DEFAULT_ORGANIZATION_ID}
|
||||
defaultOrganizationRole={DEFAULT_ORGANIZATION_ROLE}
|
||||
isSSOEnabled={isSSOEnabled}
|
||||
isSsoEnabled={isSsoEnabled}
|
||||
samlSsoEnabled={samlSsoEnabled}
|
||||
isTurnstileConfigured={IS_TURNSTILE_CONFIGURED}
|
||||
samlTenant={SAML_TENANT}
|
||||
samlProduct={SAML_PRODUCT}
|
||||
/>
|
||||
</FormWrapper>
|
||||
</div>
|
||||
|
||||
33
apps/web/modules/ee/auth/saml/api/authorize/route.ts
Normal file
33
apps/web/modules/ee/auth/saml/api/authorize/route.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import jackson from "@/modules/ee/auth/saml/lib/jackson";
|
||||
import { getIsSamlSsoEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import type { OAuthReq } from "@boxyhq/saml-jackson";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export const GET = async (req: NextRequest) => {
|
||||
const jacksonInstance = await jackson();
|
||||
if (!jacksonInstance) {
|
||||
return responses.forbiddenResponse("SAML SSO is not enabled in your Formbricks license");
|
||||
}
|
||||
const { oauthController } = jacksonInstance;
|
||||
const searchParams = Object.fromEntries(req.nextUrl.searchParams);
|
||||
const isSamlSsoEnabled = await getIsSamlSsoEnabled();
|
||||
|
||||
if (!isSamlSsoEnabled) {
|
||||
return responses.forbiddenResponse("SAML SSO is not enabled in your Formbricks license");
|
||||
}
|
||||
|
||||
try {
|
||||
const { redirect_url } = await oauthController.authorize(searchParams as OAuthReq);
|
||||
|
||||
if (!redirect_url) {
|
||||
return responses.internalServerErrorResponse("Failed to get redirect URL");
|
||||
}
|
||||
|
||||
return NextResponse.redirect(redirect_url);
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : "An unknown error occurred";
|
||||
|
||||
return responses.internalServerErrorResponse(errorMessage);
|
||||
}
|
||||
};
|
||||
32
apps/web/modules/ee/auth/saml/api/callback/route.ts
Normal file
32
apps/web/modules/ee/auth/saml/api/callback/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import jackson from "@/modules/ee/auth/saml/lib/jackson";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
interface SAMLCallbackBody {
|
||||
RelayState: string;
|
||||
SAMLResponse: string;
|
||||
}
|
||||
|
||||
export const POST = async (req: Request) => {
|
||||
const jacksonInstance = await jackson();
|
||||
if (!jacksonInstance) {
|
||||
return responses.forbiddenResponse("SAML SSO is not enabled in your Formbricks license");
|
||||
}
|
||||
const { oauthController } = jacksonInstance;
|
||||
|
||||
const formData = await req.formData();
|
||||
const body = Object.fromEntries(formData.entries());
|
||||
|
||||
const { RelayState, SAMLResponse } = body as unknown as SAMLCallbackBody;
|
||||
|
||||
const { redirect_url } = await oauthController.samlResponse({
|
||||
RelayState,
|
||||
SAMLResponse,
|
||||
});
|
||||
|
||||
if (!redirect_url) {
|
||||
return responses.internalServerErrorResponse("Failed to get redirect URL");
|
||||
}
|
||||
|
||||
return redirect(redirect_url);
|
||||
};
|
||||
18
apps/web/modules/ee/auth/saml/api/token/route.ts
Normal file
18
apps/web/modules/ee/auth/saml/api/token/route.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import jackson from "@/modules/ee/auth/saml/lib/jackson";
|
||||
import { OAuthTokenReq } from "@boxyhq/saml-jackson";
|
||||
|
||||
export const POST = async (req: Request) => {
|
||||
const jacksonInstance = await jackson();
|
||||
if (!jacksonInstance) {
|
||||
return responses.forbiddenResponse("SAML SSO is not enabled in your Formbricks license");
|
||||
}
|
||||
const { oauthController } = jacksonInstance;
|
||||
|
||||
const body = await req.formData();
|
||||
const formData = Object.fromEntries(body.entries());
|
||||
|
||||
const response = await oauthController.token(formData as unknown as OAuthTokenReq);
|
||||
|
||||
return Response.json(response);
|
||||
};
|
||||
87
apps/web/modules/ee/auth/saml/api/userinfo/lib/utils.test.ts
Normal file
87
apps/web/modules/ee/auth/saml/api/userinfo/lib/utils.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { extractAuthToken } from "./utils";
|
||||
|
||||
vi.mock("@/app/lib/api/response", () => ({
|
||||
responses: {
|
||||
unauthorizedResponse: vi.fn().mockReturnValue(new Error("Unauthorized")),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("extractAuthToken", () => {
|
||||
test("extracts token from Authorization header with Bearer prefix", () => {
|
||||
const mockRequest = new Request("https://example.com", {
|
||||
headers: {
|
||||
authorization: "Bearer token123",
|
||||
},
|
||||
});
|
||||
|
||||
const token = extractAuthToken(mockRequest);
|
||||
expect(token).toBe("token123");
|
||||
});
|
||||
|
||||
test("extracts token from Authorization header with other prefix", () => {
|
||||
const mockRequest = new Request("https://example.com", {
|
||||
headers: {
|
||||
authorization: "Custom token123",
|
||||
},
|
||||
});
|
||||
|
||||
const token = extractAuthToken(mockRequest);
|
||||
expect(token).toBe("token123");
|
||||
});
|
||||
|
||||
test("extracts token from query parameter", () => {
|
||||
const mockRequest = new Request("https://example.com?access_token=token123");
|
||||
|
||||
const token = extractAuthToken(mockRequest);
|
||||
expect(token).toBe("token123");
|
||||
});
|
||||
|
||||
test("prioritizes Authorization header over query parameter", () => {
|
||||
const mockRequest = new Request("https://example.com?access_token=queryToken", {
|
||||
headers: {
|
||||
authorization: "Bearer headerToken",
|
||||
},
|
||||
});
|
||||
|
||||
const token = extractAuthToken(mockRequest);
|
||||
expect(token).toBe("headerToken");
|
||||
});
|
||||
|
||||
test("throws unauthorized error when no token is found", () => {
|
||||
const mockRequest = new Request("https://example.com");
|
||||
|
||||
expect(() => extractAuthToken(mockRequest)).toThrow("Unauthorized");
|
||||
expect(responses.unauthorizedResponse).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("throws unauthorized error when Authorization header is empty", () => {
|
||||
const mockRequest = new Request("https://example.com", {
|
||||
headers: {
|
||||
authorization: "",
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => extractAuthToken(mockRequest)).toThrow("Unauthorized");
|
||||
expect(responses.unauthorizedResponse).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("throws unauthorized error when query parameter is empty", () => {
|
||||
const mockRequest = new Request("https://example.com?access_token=");
|
||||
|
||||
expect(() => extractAuthToken(mockRequest)).toThrow("Unauthorized");
|
||||
expect(responses.unauthorizedResponse).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handles Authorization header with only prefix", () => {
|
||||
const mockRequest = new Request("https://example.com", {
|
||||
headers: {
|
||||
authorization: "Bearer ",
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => extractAuthToken(mockRequest)).toThrow("Unauthorized");
|
||||
expect(responses.unauthorizedResponse).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
14
apps/web/modules/ee/auth/saml/api/userinfo/lib/utils.ts
Normal file
14
apps/web/modules/ee/auth/saml/api/userinfo/lib/utils.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
|
||||
export const extractAuthToken = (req: Request) => {
|
||||
const authHeader = req.headers.get("authorization");
|
||||
const parts = (authHeader || "").split(" ");
|
||||
if (parts.length > 1) return parts[1];
|
||||
|
||||
// check for query param
|
||||
const params = new URL(req.url).searchParams;
|
||||
const accessToken = params.get("access_token");
|
||||
if (accessToken) return accessToken;
|
||||
|
||||
throw responses.unauthorizedResponse();
|
||||
};
|
||||
16
apps/web/modules/ee/auth/saml/api/userinfo/route.ts
Normal file
16
apps/web/modules/ee/auth/saml/api/userinfo/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { extractAuthToken } from "@/modules/ee/auth/saml/api/userinfo/lib/utils";
|
||||
import jackson from "@/modules/ee/auth/saml/lib/jackson";
|
||||
|
||||
export const GET = async (req: Request) => {
|
||||
const jacksonInstance = await jackson();
|
||||
if (!jacksonInstance) {
|
||||
return responses.forbiddenResponse("SAML SSO is not enabled in your Formbricks license");
|
||||
}
|
||||
const { oauthController } = jacksonInstance;
|
||||
const token = extractAuthToken(req);
|
||||
|
||||
const user = await oauthController.userInfo(token);
|
||||
|
||||
return Response.json(user);
|
||||
};
|
||||
43
apps/web/modules/ee/auth/saml/lib/jackson.ts
Normal file
43
apps/web/modules/ee/auth/saml/lib/jackson.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
"use server";
|
||||
|
||||
import { preloadConnection } from "@/modules/ee/auth/saml/lib/preload-connection";
|
||||
import { getIsSamlSsoEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import type { IConnectionAPIController, IOAuthController, JacksonOption } from "@boxyhq/saml-jackson";
|
||||
import { SAML_AUDIENCE, SAML_DATABASE_URL, SAML_PATH, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
|
||||
const opts: JacksonOption = {
|
||||
externalUrl: WEBAPP_URL,
|
||||
samlAudience: SAML_AUDIENCE,
|
||||
samlPath: SAML_PATH,
|
||||
db: {
|
||||
engine: "sql",
|
||||
type: "postgres",
|
||||
url: SAML_DATABASE_URL,
|
||||
},
|
||||
};
|
||||
|
||||
declare global {
|
||||
var oauthController: IOAuthController | undefined;
|
||||
var connectionController: IConnectionAPIController | undefined;
|
||||
}
|
||||
|
||||
const g = global;
|
||||
|
||||
export default async function init() {
|
||||
if (!g.oauthController || !g.connectionController) {
|
||||
const isSamlSsoEnabled = await getIsSamlSsoEnabled();
|
||||
if (!isSamlSsoEnabled) return;
|
||||
|
||||
const ret = await (await import("@boxyhq/saml-jackson")).controllers(opts);
|
||||
|
||||
await preloadConnection(ret.connectionAPIController);
|
||||
|
||||
g.oauthController = ret.oauthController;
|
||||
g.connectionController = ret.connectionAPIController;
|
||||
}
|
||||
|
||||
return {
|
||||
oauthController: g.oauthController,
|
||||
connectionController: g.connectionController,
|
||||
};
|
||||
}
|
||||
73
apps/web/modules/ee/auth/saml/lib/preload-connection.ts
Normal file
73
apps/web/modules/ee/auth/saml/lib/preload-connection.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { SAMLSSOConnectionWithEncodedMetadata, SAMLSSORecord } from "@boxyhq/saml-jackson";
|
||||
import { ConnectionAPIController } from "@boxyhq/saml-jackson/dist/controller/api";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { SAML_PRODUCT, SAML_TENANT, SAML_XML_DIR, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
|
||||
const getPreloadedConnectionFile = async () => {
|
||||
const preloadedConnections = await fs.readdir(path.join(SAML_XML_DIR));
|
||||
const xmlFiles = preloadedConnections.filter((file) => file.endsWith(".xml"));
|
||||
if (xmlFiles.length === 0) {
|
||||
throw new Error("No preloaded connection file found");
|
||||
}
|
||||
return xmlFiles[0];
|
||||
};
|
||||
|
||||
const getPreloadedConnectionMetadata = async () => {
|
||||
const preloadedConnectionFile = await getPreloadedConnectionFile();
|
||||
|
||||
const preloadedConnectionMetadata = await fs.readFile(
|
||||
path.join(SAML_XML_DIR, preloadedConnectionFile),
|
||||
"utf8"
|
||||
);
|
||||
return preloadedConnectionMetadata;
|
||||
};
|
||||
|
||||
const getConnectionPayload = (metadata: string): SAMLSSOConnectionWithEncodedMetadata => {
|
||||
const encodedRawMetadata = Buffer.from(metadata, "utf8").toString("base64");
|
||||
|
||||
return {
|
||||
name: "SAML SSO",
|
||||
defaultRedirectUrl: `${WEBAPP_URL}/auth/login`,
|
||||
redirectUrl: [`${WEBAPP_URL}/*`],
|
||||
tenant: SAML_TENANT,
|
||||
product: SAML_PRODUCT,
|
||||
encodedRawMetadata,
|
||||
};
|
||||
};
|
||||
|
||||
export const preloadConnection = async (connectionController: ConnectionAPIController) => {
|
||||
try {
|
||||
const preloadedConnectionMetadata = await getPreloadedConnectionMetadata();
|
||||
|
||||
if (!preloadedConnectionMetadata) {
|
||||
console.log("No preloaded connection metadata found");
|
||||
return;
|
||||
}
|
||||
|
||||
const connections = await connectionController.getConnections({
|
||||
tenant: SAML_TENANT,
|
||||
product: SAML_PRODUCT,
|
||||
});
|
||||
|
||||
const existingConnection = connections[0];
|
||||
|
||||
const connection = getConnectionPayload(preloadedConnectionMetadata);
|
||||
let newConnection: SAMLSSORecord;
|
||||
try {
|
||||
newConnection = await connectionController.createSAMLConnection(connection);
|
||||
} catch (error) {
|
||||
throw new Error(`Metadata is not valid\n${error.message}`);
|
||||
}
|
||||
if (newConnection && existingConnection && newConnection.clientID !== existingConnection.clientID) {
|
||||
await connectionController.deleteConnections({
|
||||
clientID: existingConnection.clientID,
|
||||
clientSecret: existingConnection.clientSecret,
|
||||
product: existingConnection.product,
|
||||
tenant: existingConnection.tenant,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error preloading connection:", error.message);
|
||||
}
|
||||
};
|
||||
110
apps/web/modules/ee/auth/saml/lib/tests/jackson.test.ts
Normal file
110
apps/web/modules/ee/auth/saml/lib/tests/jackson.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { preloadConnection } from "@/modules/ee/auth/saml/lib/preload-connection";
|
||||
import { getIsSamlSsoEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { controllers } from "@boxyhq/saml-jackson";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { SAML_AUDIENCE, SAML_DATABASE_URL, SAML_PATH, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import init from "../jackson";
|
||||
|
||||
vi.mock("@formbricks/lib/constants", () => ({
|
||||
SAML_AUDIENCE: "test-audience",
|
||||
SAML_DATABASE_URL: "test-db-url",
|
||||
SAML_PATH: "/test-path",
|
||||
WEBAPP_URL: "https://test-webapp-url.com",
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||
getIsSamlSsoEnabled: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/auth/saml/lib/preload-connection", () => ({
|
||||
preloadConnection: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@boxyhq/saml-jackson", () => ({
|
||||
controllers: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("SAML Jackson Initialization", () => {
|
||||
const mockOAuthController = { name: "mockOAuthController" };
|
||||
const mockConnectionController = { name: "mockConnectionController" };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
global.oauthController = undefined;
|
||||
global.connectionController = undefined;
|
||||
|
||||
vi.mocked(controllers).mockResolvedValue({
|
||||
oauthController: mockOAuthController,
|
||||
connectionAPIController: mockConnectionController,
|
||||
} as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
test("initialize controllers when SAML SSO is enabled", async () => {
|
||||
vi.mocked(getIsSamlSsoEnabled).mockResolvedValue(true);
|
||||
|
||||
const result = await init();
|
||||
|
||||
expect(getIsSamlSsoEnabled).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(controllers).toHaveBeenCalledWith({
|
||||
externalUrl: WEBAPP_URL,
|
||||
samlAudience: SAML_AUDIENCE,
|
||||
samlPath: SAML_PATH,
|
||||
db: {
|
||||
engine: "sql",
|
||||
type: "postgres",
|
||||
url: SAML_DATABASE_URL,
|
||||
},
|
||||
});
|
||||
|
||||
expect(preloadConnection).toHaveBeenCalledWith(mockConnectionController);
|
||||
|
||||
expect(global.oauthController).toBe(mockOAuthController);
|
||||
expect(global.connectionController).toBe(mockConnectionController);
|
||||
|
||||
expect(result).toEqual({
|
||||
oauthController: mockOAuthController,
|
||||
connectionController: mockConnectionController,
|
||||
});
|
||||
});
|
||||
|
||||
test("return early when SAML SSO is disabled", async () => {
|
||||
vi.mocked(getIsSamlSsoEnabled).mockResolvedValue(false);
|
||||
|
||||
const result = await init();
|
||||
|
||||
expect(getIsSamlSsoEnabled).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(controllers).not.toHaveBeenCalled();
|
||||
|
||||
expect(preloadConnection).not.toHaveBeenCalled();
|
||||
|
||||
expect(global.oauthController).toBeUndefined();
|
||||
expect(global.connectionController).toBeUndefined();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
test("reuse existing controllers if already initialized", async () => {
|
||||
global.oauthController = mockOAuthController as any;
|
||||
global.connectionController = mockConnectionController as any;
|
||||
|
||||
const result = await init();
|
||||
|
||||
expect(getIsSamlSsoEnabled).not.toHaveBeenCalled();
|
||||
|
||||
expect(controllers).not.toHaveBeenCalled();
|
||||
|
||||
expect(preloadConnection).not.toHaveBeenCalled();
|
||||
|
||||
expect(result).toEqual({
|
||||
oauthController: mockOAuthController,
|
||||
connectionController: mockConnectionController,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { SAML_PRODUCT, SAML_TENANT, SAML_XML_DIR, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { preloadConnection } from "../preload-connection";
|
||||
|
||||
vi.mock("@formbricks/lib/constants", () => ({
|
||||
SAML_PRODUCT: "test-product",
|
||||
SAML_TENANT: "test-tenant",
|
||||
SAML_XML_DIR: "test-xml-dir",
|
||||
WEBAPP_URL: "https://test-webapp-url.com",
|
||||
}));
|
||||
|
||||
vi.mock("fs/promises", () => ({
|
||||
default: {
|
||||
readdir: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("path", () => ({
|
||||
default: {
|
||||
join: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@boxyhq/saml-jackson", () => ({
|
||||
SAMLSSOConnectionWithEncodedMetadata: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@boxyhq/saml-jackson/dist/controller/api", () => ({
|
||||
ConnectionAPIController: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("SAML Preload Connection", () => {
|
||||
const mockConnectionController = {
|
||||
getConnections: vi.fn(),
|
||||
createSAMLConnection: vi.fn(),
|
||||
deleteConnections: vi.fn(),
|
||||
};
|
||||
|
||||
const mockMetadata = "<EntityDescriptor>SAML Metadata</EntityDescriptor>";
|
||||
const mockEncodedMetadata = Buffer.from(mockMetadata, "utf8").toString("base64");
|
||||
|
||||
const mockExistingConnection = {
|
||||
clientID: "existing-client-id",
|
||||
clientSecret: "existing-client-secret",
|
||||
product: SAML_PRODUCT,
|
||||
tenant: SAML_TENANT,
|
||||
};
|
||||
|
||||
const mockNewConnection = {
|
||||
clientID: "new-client-id",
|
||||
clientSecret: "new-client-secret",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
vi.mocked(path.join).mockImplementation((...args) => args.join("/"));
|
||||
|
||||
vi.mocked(fs.readdir).mockResolvedValue(["metadata.xml", "other-file.txt"] as any);
|
||||
|
||||
vi.mocked(fs.readFile).mockResolvedValue(mockMetadata as any);
|
||||
|
||||
mockConnectionController.getConnections.mockResolvedValue([mockExistingConnection]);
|
||||
|
||||
mockConnectionController.createSAMLConnection.mockResolvedValue(mockNewConnection);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
test("preload connection from XML file", async () => {
|
||||
await preloadConnection(mockConnectionController as any);
|
||||
|
||||
expect(fs.readdir).toHaveBeenCalledWith(path.join(SAML_XML_DIR));
|
||||
|
||||
expect(fs.readFile).toHaveBeenCalledWith(path.join(SAML_XML_DIR, "metadata.xml"), "utf8");
|
||||
|
||||
expect(mockConnectionController.getConnections).toHaveBeenCalledWith({
|
||||
tenant: SAML_TENANT,
|
||||
product: SAML_PRODUCT,
|
||||
});
|
||||
|
||||
expect(mockConnectionController.createSAMLConnection).toHaveBeenCalledWith({
|
||||
name: "SAML SSO",
|
||||
defaultRedirectUrl: `${WEBAPP_URL}/auth/login`,
|
||||
redirectUrl: [`${WEBAPP_URL}/*`],
|
||||
tenant: SAML_TENANT,
|
||||
product: SAML_PRODUCT,
|
||||
encodedRawMetadata: mockEncodedMetadata,
|
||||
});
|
||||
|
||||
expect(mockConnectionController.deleteConnections).toHaveBeenCalledWith({
|
||||
clientID: mockExistingConnection.clientID,
|
||||
clientSecret: mockExistingConnection.clientSecret,
|
||||
product: mockExistingConnection.product,
|
||||
tenant: mockExistingConnection.tenant,
|
||||
});
|
||||
});
|
||||
|
||||
test("not delete existing connection if client IDs match", async () => {
|
||||
mockConnectionController.createSAMLConnection.mockResolvedValue({
|
||||
clientID: mockExistingConnection.clientID,
|
||||
});
|
||||
|
||||
await preloadConnection(mockConnectionController as any);
|
||||
|
||||
expect(mockConnectionController.deleteConnections).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handle case when no XML files are found", async () => {
|
||||
vi.mocked(fs.readdir).mockResolvedValue(["other-file.txt"] as any);
|
||||
|
||||
const consoleErrorSpy = vi.spyOn(console, "error");
|
||||
|
||||
await preloadConnection(mockConnectionController as any);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"Error preloading connection:",
|
||||
expect.stringContaining("No preloaded connection file found")
|
||||
);
|
||||
|
||||
expect(mockConnectionController.createSAMLConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handle invalid metadata", async () => {
|
||||
const errorMessage = "Invalid metadata";
|
||||
mockConnectionController.createSAMLConnection.mockRejectedValue(new Error(errorMessage));
|
||||
|
||||
const consoleErrorSpy = vi.spyOn(console, "error");
|
||||
|
||||
await preloadConnection(mockConnectionController as any);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"Error preloading connection:",
|
||||
expect.stringContaining(errorMessage)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import { getContactAttributes } from "@/modules/ee/contacts/lib/contact-attributes";
|
||||
import { getContact } from "@/modules/ee/contacts/lib/contacts";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import { getResponsesByContactId } from "@formbricks/lib/response/service";
|
||||
import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings";
|
||||
import { getContact, getContactAttributes } from "../../lib/contacts";
|
||||
|
||||
export const AttributesSection = async ({ contactId }: { contactId: string }) => {
|
||||
const t = await getTranslate();
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { AttributesSection } from "@/modules/ee/contacts/[contactId]/components/attributes-section";
|
||||
import { DeleteContactButton } from "@/modules/ee/contacts/[contactId]/components/delete-contact-button";
|
||||
import { getContact, getContactAttributes } from "@/modules/ee/contacts/lib/contacts";
|
||||
import { getContactAttributes } from "@/modules/ee/contacts/lib/contact-attributes";
|
||||
import { getContact } from "@/modules/ee/contacts/lib/contacts";
|
||||
import { getContactIdentifier } from "@/modules/ee/contacts/lib/utils";
|
||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { contactAttributeCache } from "@/lib/cache/contact-attribute";
|
||||
import { contactAttributeKeyCache } from "@/lib/cache/contact-attribute-key";
|
||||
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
|
||||
import { hasEmailAttribute } from "@/modules/ee/contacts/lib/contact-attributes";
|
||||
import { prisma } from "@formbricks/database";
|
||||
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";
|
||||
import { getContactAttributeKeys } from "./contacts";
|
||||
|
||||
export const updateAttributes = async (
|
||||
contactId: string,
|
||||
@@ -24,24 +25,7 @@ export const updateAttributes = async (
|
||||
const [contactAttributeKeys, existingEmailAttribute] = await Promise.all([
|
||||
getContactAttributeKeys(environmentId),
|
||||
contactAttributesParam.email
|
||||
? prisma.contactAttribute.findFirst({
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
attributeKey: {
|
||||
key: "email",
|
||||
},
|
||||
value: contactAttributesParam.email,
|
||||
},
|
||||
{
|
||||
NOT: {
|
||||
contactId,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
? hasEmailAttribute(contactAttributesParam.email, environmentId, contactId)
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
|
||||
|
||||
20
apps/web/modules/ee/contacts/lib/contact-attribute-keys.ts
Normal file
20
apps/web/modules/ee/contacts/lib/contact-attribute-keys.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
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 { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
|
||||
export const getContactAttributeKeys = reactCache(
|
||||
(environmentId: string): Promise<TContactAttributeKey[]> =>
|
||||
cache(
|
||||
async () => {
|
||||
return await prisma.contactAttributeKey.findMany({
|
||||
where: { environmentId },
|
||||
});
|
||||
},
|
||||
[`getContactAttributeKeys-${environmentId}`],
|
||||
{
|
||||
tags: [contactAttributeKeyCache.tag.byEnvironmentId(environmentId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
92
apps/web/modules/ee/contacts/lib/contact-attributes.ts
Normal file
92
apps/web/modules/ee/contacts/lib/contact-attributes.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { contactAttributeCache } from "@/lib/cache/contact-attribute";
|
||||
import { contactAttributeKeyCache } from "@/lib/cache/contact-attribute-key";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { validateInputs } from "@formbricks/lib/utils/validate";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { ZUserEmail } from "@formbricks/types/user";
|
||||
|
||||
const selectContactAttribute = {
|
||||
value: true,
|
||||
attributeKey: {
|
||||
select: {
|
||||
key: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.ContactAttributeSelect;
|
||||
|
||||
export const getContactAttributes = reactCache((contactId: string) =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([contactId, ZId]);
|
||||
|
||||
try {
|
||||
const prismaAttributes = await prisma.contactAttribute.findMany({
|
||||
where: {
|
||||
contactId,
|
||||
},
|
||||
select: selectContactAttribute,
|
||||
});
|
||||
|
||||
return prismaAttributes.reduce((acc, attr) => {
|
||||
acc[attr.attributeKey.key] = attr.value;
|
||||
return acc;
|
||||
}, {}) as TContactAttributes;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getContactAttributes-${contactId}`],
|
||||
{
|
||||
tags: [contactAttributeCache.tag.byContactId(contactId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
export const hasEmailAttribute = reactCache(
|
||||
async (email: string, environmentId: string, contactId: string): Promise<boolean> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([email, ZUserEmail], [environmentId, ZId], [contactId, ZId]);
|
||||
|
||||
const contactAttribute = await prisma.contactAttribute.findFirst({
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
attributeKey: {
|
||||
key: "email",
|
||||
environmentId,
|
||||
},
|
||||
value: email,
|
||||
},
|
||||
{
|
||||
NOT: {
|
||||
contactId,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
return !!contactAttribute;
|
||||
},
|
||||
[`hasEmailAttribute-${email}-${environmentId}-${contactId}`],
|
||||
{
|
||||
tags: [
|
||||
contactAttributeKeyCache.tag.byEnvironmentIdAndKey(environmentId, "email"),
|
||||
contactAttributeCache.tag.byEnvironmentId(environmentId),
|
||||
contactAttributeCache.tag.byContactId(contactId),
|
||||
],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -9,8 +9,6 @@ import { cache } from "@formbricks/lib/cache";
|
||||
import { ITEMS_PER_PAGE } from "@formbricks/lib/constants";
|
||||
import { validateInputs } from "@formbricks/lib/utils/validate";
|
||||
import { ZId, ZOptionalNumber, ZOptionalString } from "@formbricks/types/common";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
|
||||
import {
|
||||
TContact,
|
||||
@@ -39,16 +37,6 @@ const selectContact = {
|
||||
},
|
||||
} satisfies Prisma.ContactSelect;
|
||||
|
||||
const selectContactAttribute = {
|
||||
value: true,
|
||||
attributeKey: {
|
||||
select: {
|
||||
key: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.ContactAttributeSelect;
|
||||
|
||||
const buildContactWhereClause = (environmentId: string, search?: string): Prisma.ContactWhereInput => {
|
||||
const whereClause: Prisma.ContactWhereInput = { environmentId };
|
||||
|
||||
@@ -149,6 +137,7 @@ export const deleteContact = async (contactId: string): Promise<TContact | null>
|
||||
});
|
||||
|
||||
const contactUserId = contact.attributes.find((attr) => attr.attributeKey.key === "userId")?.value;
|
||||
const contactAttributes = contact.attributes;
|
||||
|
||||
contactCache.revalidate({
|
||||
id: contact.id,
|
||||
@@ -156,6 +145,19 @@ export const deleteContact = async (contactId: string): Promise<TContact | null>
|
||||
userId: contactUserId,
|
||||
});
|
||||
|
||||
for (const attr of contactAttributes) {
|
||||
contactAttributeCache.revalidate({
|
||||
contactId: contact.id,
|
||||
key: attr.attributeKey.key,
|
||||
environmentId: contact.environmentId,
|
||||
});
|
||||
|
||||
contactAttributeKeyCache.revalidate({
|
||||
environmentId: contact.environmentId,
|
||||
key: attr.attributeKey.key,
|
||||
});
|
||||
}
|
||||
|
||||
return contact;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
@@ -166,54 +168,6 @@ export const deleteContact = async (contactId: string): Promise<TContact | null>
|
||||
}
|
||||
};
|
||||
|
||||
export const getContactAttributes = reactCache((contactId: string) =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([contactId, ZId]);
|
||||
|
||||
try {
|
||||
const prismaAttributes = await prisma.contactAttribute.findMany({
|
||||
where: {
|
||||
contactId,
|
||||
},
|
||||
select: selectContactAttribute,
|
||||
});
|
||||
|
||||
// return convertPrismaContactAttributes(prismaAttributes);
|
||||
return prismaAttributes.reduce((acc, attr) => {
|
||||
acc[attr.attributeKey.key] = attr.value;
|
||||
return acc;
|
||||
}, {}) as TContactAttributes;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getContactAttributes-${contactId}`],
|
||||
{
|
||||
tags: [contactAttributeCache.tag.byContactId(contactId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
export const getContactAttributeKeys = reactCache(
|
||||
(environmentId: string): Promise<TContactAttributeKey[]> =>
|
||||
cache(
|
||||
async () => {
|
||||
return await prisma.contactAttributeKey.findMany({
|
||||
where: { environmentId },
|
||||
});
|
||||
},
|
||||
[`getContactAttributeKeys-${environmentId}`],
|
||||
{
|
||||
tags: [contactAttributeKeyCache.tag.byEnvironmentId(environmentId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
export const createContactsFromCSV = async (
|
||||
csvData: Record<string, string>[],
|
||||
environmentId: string,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { contactCache } from "@/lib/cache/contact";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { UploadContactsCSVButton } from "@/modules/ee/contacts/components/upload-contacts-button";
|
||||
import { getContactAttributeKeys, getContacts } from "@/modules/ee/contacts/lib/contacts";
|
||||
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
|
||||
import { getContacts } from "@/modules/ee/contacts/lib/contacts";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { ContactsSecondaryNavigation } from "@/modules/ee/contacts/components/contacts-secondary-navigation";
|
||||
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contacts";
|
||||
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
|
||||
import { SegmentTable } from "@/modules/ee/contacts/segments/components/segment-table";
|
||||
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
|
||||
@@ -90,6 +90,7 @@ const fetchLicenseForE2ETesting = async (): Promise<{
|
||||
whitelabel: true,
|
||||
removeBranding: true,
|
||||
ai: true,
|
||||
saml: true,
|
||||
},
|
||||
lastChecked: currentTime,
|
||||
};
|
||||
@@ -156,6 +157,7 @@ export const getEnterpriseLicense = async (): Promise<{
|
||||
removeBranding: false,
|
||||
contacts: false,
|
||||
ai: false,
|
||||
saml: false,
|
||||
},
|
||||
lastChecked: new Date(),
|
||||
};
|
||||
@@ -361,7 +363,7 @@ export const getIsTwoFactorAuthEnabled = async (): Promise<boolean> => {
|
||||
return licenseFeatures.twoFactorAuth;
|
||||
};
|
||||
|
||||
export const getIsSSOEnabled = async (): Promise<boolean> => {
|
||||
export const getisSsoEnabled = async (): Promise<boolean> => {
|
||||
if (E2E_TESTING) {
|
||||
const previousResult = await fetchLicenseForE2ETesting();
|
||||
return previousResult && previousResult.features ? previousResult.features.sso : false;
|
||||
@@ -371,6 +373,21 @@ export const getIsSSOEnabled = async (): Promise<boolean> => {
|
||||
return licenseFeatures.sso;
|
||||
};
|
||||
|
||||
export const getIsSamlSsoEnabled = async (): Promise<boolean> => {
|
||||
if (E2E_TESTING) {
|
||||
const previousResult = await fetchLicenseForE2ETesting();
|
||||
return previousResult && previousResult.features
|
||||
? previousResult.features.sso && previousResult.features.saml
|
||||
: false;
|
||||
}
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
return false;
|
||||
}
|
||||
const licenseFeatures = await getLicenseFeatures();
|
||||
if (!licenseFeatures) return false;
|
||||
return licenseFeatures.sso && licenseFeatures.saml;
|
||||
};
|
||||
|
||||
export const getIsOrganizationAIReady = async (billingPlan: Organization["billing"]["plan"]) => {
|
||||
if (!IS_AI_CONFIGURED) return false;
|
||||
if (E2E_TESTING) {
|
||||
|
||||
@@ -12,6 +12,7 @@ const ZEnterpriseLicenseFeatures = z.object({
|
||||
removeBranding: z.boolean(),
|
||||
twoFactorAuth: z.boolean(),
|
||||
sso: z.boolean(),
|
||||
saml: z.boolean(),
|
||||
ai: z.boolean(),
|
||||
});
|
||||
|
||||
|
||||
21
apps/web/modules/ee/sso/actions.ts
Normal file
21
apps/web/modules/ee/sso/actions.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
"use server";
|
||||
|
||||
import { actionClient } from "@/lib/utils/action-client";
|
||||
import jackson from "@/modules/ee/auth/saml/lib/jackson";
|
||||
import { SAML_PRODUCT, SAML_TENANT } from "@formbricks/lib/constants";
|
||||
|
||||
export const doesSamlConnectionExistAction = actionClient.action(async () => {
|
||||
const jacksonInstance = await jackson();
|
||||
|
||||
if (!jacksonInstance) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { connectionController } = jacksonInstance;
|
||||
const connection = await connectionController.getConnections({
|
||||
product: SAML_PRODUCT,
|
||||
tenant: SAML_TENANT,
|
||||
});
|
||||
|
||||
return connection.length === 1;
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import { useCallback, useEffect } from "react";
|
||||
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@formbricks/lib/localStorage";
|
||||
|
||||
interface AzureButtonProps {
|
||||
inviteUrl?: string | null;
|
||||
inviteUrl?: string;
|
||||
directRedirect?: boolean;
|
||||
lastUsed?: boolean;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { signIn } from "next-auth/react";
|
||||
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@formbricks/lib/localStorage";
|
||||
|
||||
interface GithubButtonProps {
|
||||
inviteUrl?: string | null;
|
||||
inviteUrl?: string;
|
||||
lastUsed?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { signIn } from "next-auth/react";
|
||||
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@formbricks/lib/localStorage";
|
||||
|
||||
interface GoogleButtonProps {
|
||||
inviteUrl?: string | null;
|
||||
inviteUrl?: string;
|
||||
lastUsed?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useCallback, useEffect } from "react";
|
||||
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@formbricks/lib/localStorage";
|
||||
|
||||
interface OpenIdButtonProps {
|
||||
inviteUrl?: string | null;
|
||||
inviteUrl?: string;
|
||||
lastUsed?: boolean;
|
||||
directRedirect?: boolean;
|
||||
text?: string;
|
||||
|
||||
61
apps/web/modules/ee/sso/components/saml-button.tsx
Normal file
61
apps/web/modules/ee/sso/components/saml-button.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { doesSamlConnectionExistAction } from "@/modules/ee/sso/actions";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { LockIcon } from "lucide-react";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@formbricks/lib/localStorage";
|
||||
|
||||
interface SamlButtonProps {
|
||||
inviteUrl?: string;
|
||||
lastUsed?: boolean;
|
||||
samlTenant: string;
|
||||
samlProduct: string;
|
||||
}
|
||||
|
||||
export const SamlButton = ({ inviteUrl, lastUsed, samlTenant, samlProduct }: SamlButtonProps) => {
|
||||
const { t } = useTranslate();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem(FORMBRICKS_LOGGED_IN_WITH_LS, "Saml");
|
||||
}
|
||||
setIsLoading(true);
|
||||
const doesSamlConnectionExist = await doesSamlConnectionExistAction();
|
||||
if (!doesSamlConnectionExist?.data) {
|
||||
toast.error(t("auth.saml_connection_error"));
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
signIn(
|
||||
"saml",
|
||||
{
|
||||
redirect: true,
|
||||
callbackUrl: inviteUrl ? inviteUrl : "/", // redirect after login to /
|
||||
},
|
||||
{
|
||||
tenant: samlTenant,
|
||||
product: samlProduct,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleLogin}
|
||||
variant="secondary"
|
||||
className="relative w-full justify-center"
|
||||
loading={isLoading}>
|
||||
{t("auth.continue_with_saml")}
|
||||
|
||||
<LockIcon />
|
||||
{lastUsed && <span className="absolute right-3 text-xs opacity-50">{t("auth.last_used")}</span>}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -1,10 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@formbricks/lib/localStorage";
|
||||
import { AzureButton } from "./azure-button";
|
||||
import { GithubButton } from "./github-button";
|
||||
import { GoogleButton } from "./google-button";
|
||||
import { OpenIdButton } from "./open-id-button";
|
||||
import { SamlButton } from "./saml-button";
|
||||
|
||||
interface SSOOptionsProps {
|
||||
googleOAuthEnabled: boolean;
|
||||
@@ -13,6 +16,9 @@ interface SSOOptionsProps {
|
||||
oidcOAuthEnabled: boolean;
|
||||
oidcDisplayName?: string;
|
||||
callbackUrl: string;
|
||||
samlSsoEnabled: boolean;
|
||||
samlTenant: string;
|
||||
samlProduct: string;
|
||||
}
|
||||
|
||||
export const SSOOptions = ({
|
||||
@@ -22,16 +28,42 @@ export const SSOOptions = ({
|
||||
oidcOAuthEnabled,
|
||||
oidcDisplayName,
|
||||
callbackUrl,
|
||||
samlSsoEnabled,
|
||||
samlTenant,
|
||||
samlProduct,
|
||||
}: SSOOptionsProps) => {
|
||||
const { t } = useTranslate();
|
||||
const [lastLoggedInWith, setLastLoggedInWith] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
setLastLoggedInWith(localStorage.getItem(FORMBRICKS_LOGGED_IN_WITH_LS) || "");
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{googleOAuthEnabled && <GoogleButton inviteUrl={callbackUrl} />}
|
||||
{githubOAuthEnabled && <GithubButton inviteUrl={callbackUrl} />}
|
||||
{azureOAuthEnabled && <AzureButton inviteUrl={callbackUrl} />}
|
||||
{googleOAuthEnabled && (
|
||||
<GoogleButton inviteUrl={callbackUrl} lastUsed={lastLoggedInWith === "Google"} />
|
||||
)}
|
||||
{githubOAuthEnabled && (
|
||||
<GithubButton inviteUrl={callbackUrl} lastUsed={lastLoggedInWith === "Github"} />
|
||||
)}
|
||||
{azureOAuthEnabled && <AzureButton inviteUrl={callbackUrl} lastUsed={lastLoggedInWith === "Azure"} />}
|
||||
{oidcOAuthEnabled && (
|
||||
<OpenIdButton inviteUrl={callbackUrl} text={t("auth.continue_with_oidc", { oidcDisplayName })} />
|
||||
<OpenIdButton
|
||||
inviteUrl={callbackUrl}
|
||||
lastUsed={lastLoggedInWith === "OpenID"}
|
||||
text={t("auth.continue_with_oidc", { oidcDisplayName })}
|
||||
/>
|
||||
)}
|
||||
{samlSsoEnabled && (
|
||||
<SamlButton
|
||||
inviteUrl={callbackUrl}
|
||||
lastUsed={lastLoggedInWith === "Saml"}
|
||||
samlTenant={samlTenant}
|
||||
samlProduct={samlProduct}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
OIDC_DISPLAY_NAME,
|
||||
OIDC_ISSUER,
|
||||
OIDC_SIGNING_ALGORITHM,
|
||||
WEBAPP_URL,
|
||||
} from "@formbricks/lib/constants";
|
||||
|
||||
export const getSSOProviders = () => [
|
||||
@@ -54,6 +55,36 @@ export const getSSOProviders = () => [
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "saml",
|
||||
name: "BoxyHQ SAML",
|
||||
type: "oauth" as const,
|
||||
version: "2.0",
|
||||
checks: ["pkce" as const, "state" as const],
|
||||
authorization: {
|
||||
url: `${WEBAPP_URL}/api/auth/saml/authorize`,
|
||||
params: {
|
||||
scope: "",
|
||||
response_type: "code",
|
||||
provider: "saml",
|
||||
},
|
||||
},
|
||||
token: `${WEBAPP_URL}/api/auth/saml/token`,
|
||||
userinfo: `${WEBAPP_URL}/api/auth/saml/userinfo`,
|
||||
profile(profile) {
|
||||
return {
|
||||
id: profile.id,
|
||||
email: profile.email,
|
||||
name: [profile.firstName, profile.lastName].filter(Boolean).join(" "),
|
||||
image: null,
|
||||
};
|
||||
},
|
||||
options: {
|
||||
clientId: "dummy",
|
||||
clientSecret: "dummy",
|
||||
},
|
||||
allowDangerousEmailAccountLinking: true,
|
||||
},
|
||||
];
|
||||
|
||||
export type { IdentityProvider };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createBrevoCustomer } from "@/modules/auth/lib/brevo";
|
||||
import { getUserByEmail, updateUser } from "@/modules/auth/lib/user";
|
||||
import { createUser } from "@/modules/auth/lib/user";
|
||||
import { getIsSamlSsoEnabled, getisSsoEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import type { IdentityProvider } from "@prisma/client";
|
||||
import type { Account } from "next-auth";
|
||||
import { prisma } from "@formbricks/database";
|
||||
@@ -12,12 +13,25 @@ import { findMatchingLocale } from "@formbricks/lib/utils/locale";
|
||||
import type { TUser, TUserNotificationSettings } from "@formbricks/types/user";
|
||||
|
||||
export const handleSSOCallback = async ({ user, account }: { user: TUser; account: Account }) => {
|
||||
const isSsoEnabled = await getisSsoEnabled();
|
||||
if (!isSsoEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!user.email || account.type !== "oauth") {
|
||||
return false;
|
||||
}
|
||||
|
||||
let provider = account.provider.toLowerCase().replace("-", "") as IdentityProvider;
|
||||
|
||||
if (provider === "saml") {
|
||||
const isSamlSsoEnabled = await getIsSamlSsoEnabled();
|
||||
if (!isSamlSsoEnabled) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (account.provider) {
|
||||
const provider = account.provider.toLowerCase().replace("-", "") as IdentityProvider;
|
||||
// check if accounts for this provider / account Id already exists
|
||||
const existingUserWithAccount = await prisma.user.findFirst({
|
||||
include: {
|
||||
|
||||
@@ -138,7 +138,7 @@ export const EditLogo = ({ project, environmentId, isReadOnly }: EditLogoProps)
|
||||
) : (
|
||||
<FileInput
|
||||
id="logo-input"
|
||||
allowedFileExtensions={["png", "jpeg", "jpg", "webp"]}
|
||||
allowedFileExtensions={["png", "jpeg", "jpg", "webp", "heic"]}
|
||||
environmentId={environmentId}
|
||||
onFileUpload={(files: string[]) => {
|
||||
setLogoUrl(files[0]);
|
||||
@@ -151,7 +151,7 @@ export const EditLogo = ({ project, environmentId, isReadOnly }: EditLogoProps)
|
||||
<Input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg, image/png, image/webp"
|
||||
accept="image/jpeg, image/png, image/webp, image/heic"
|
||||
className="hidden"
|
||||
disabled={isReadOnly}
|
||||
onChange={handleFileChange}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SignupForm } from "@/modules/auth/signup/components/signup-form";
|
||||
import { getIsSSOEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getIsSamlSsoEnabled, getisSsoEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import { Metadata } from "next";
|
||||
import {
|
||||
@@ -14,6 +14,9 @@ import {
|
||||
OIDC_DISPLAY_NAME,
|
||||
OIDC_OAUTH_ENABLED,
|
||||
PRIVACY_URL,
|
||||
SAML_OAUTH_ENABLED,
|
||||
SAML_PRODUCT,
|
||||
SAML_TENANT,
|
||||
TERMS_URL,
|
||||
WEBAPP_URL,
|
||||
} from "@formbricks/lib/constants";
|
||||
@@ -26,7 +29,11 @@ export const metadata: Metadata = {
|
||||
|
||||
export const SignupPage = async () => {
|
||||
const locale = await findMatchingLocale();
|
||||
const isSSOEnabled = await getIsSSOEnabled();
|
||||
|
||||
const [isSsoEnabled, isSamlSsoEnabled] = await Promise.all([getisSsoEnabled(), getIsSamlSsoEnabled()]);
|
||||
|
||||
const samlSsoEnabled = isSamlSsoEnabled && SAML_OAUTH_ENABLED;
|
||||
|
||||
const t = await getTranslate();
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
@@ -47,8 +54,11 @@ export const SignupPage = async () => {
|
||||
userLocale={locale}
|
||||
defaultOrganizationId={DEFAULT_ORGANIZATION_ID}
|
||||
defaultOrganizationRole={DEFAULT_ORGANIZATION_ROLE}
|
||||
isSSOEnabled={isSSOEnabled}
|
||||
isSsoEnabled={isSsoEnabled}
|
||||
samlSsoEnabled={samlSsoEnabled}
|
||||
isTurnstileConfigured={IS_TURNSTILE_CONFIGURED}
|
||||
samlTenant={SAML_TENANT}
|
||||
samlProduct={SAML_PRODUCT}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -308,7 +308,7 @@ export const QuestionFormInput = ({
|
||||
{showImageUploader && id === "headline" && (
|
||||
<FileInput
|
||||
id="question-image"
|
||||
allowedFileExtensions={["png", "jpeg", "jpg", "webp"]}
|
||||
allowedFileExtensions={["png", "jpeg", "jpg", "webp", "heic"]}
|
||||
environmentId={localSurvey.environmentId}
|
||||
onFileUpload={(url: string[] | undefined, fileType: "image" | "video") => {
|
||||
if (url) {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
getCXQuestionTypes,
|
||||
getQuestionDefaults,
|
||||
getQuestionTypes,
|
||||
universalQuestionPresets,
|
||||
} from "@/modules/survey/lib/questions";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { Project } from "@prisma/client";
|
||||
@@ -8,12 +14,6 @@ import { useTranslate } from "@tolgee/react";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import {
|
||||
getCXQuestionTypes,
|
||||
getQuestionDefaults,
|
||||
getQuestionTypes,
|
||||
universalQuestionPresets,
|
||||
} from "@formbricks/lib/utils/questions";
|
||||
|
||||
interface AddQuestionButtonProps {
|
||||
addQuestion: (question: any) => void;
|
||||
|
||||
@@ -114,7 +114,7 @@ export const EditWelcomeCard = ({
|
||||
<div className="mt-3 flex w-full items-center justify-center">
|
||||
<FileInput
|
||||
id="welcome-card-image"
|
||||
allowedFileExtensions={["png", "jpeg", "jpg", "webp"]}
|
||||
allowedFileExtensions={["png", "jpeg", "jpg", "webp", "heic"]}
|
||||
environmentId={environmentId}
|
||||
onFileUpload={(url: string[]) => {
|
||||
updateSurvey({ fileUrl: url[0] });
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
getCXQuestionNameMap,
|
||||
getQuestionDefaults,
|
||||
getQuestionIconMap,
|
||||
getQuestionNameMap,
|
||||
} from "@/modules/survey/lib/questions";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
|
||||
import {
|
||||
@@ -17,12 +23,6 @@ import { Project } from "@prisma/client";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { ArrowDownIcon, ArrowUpIcon, CopyIcon, EllipsisIcon, TrashIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
getCXQuestionNameMap,
|
||||
getQuestionDefaults,
|
||||
getQuestionIconMap,
|
||||
getQuestionNameMap,
|
||||
} from "@formbricks/lib/utils/questions";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyEndScreenCard,
|
||||
|
||||
@@ -16,7 +16,7 @@ export const UploadImageSurveyBg = ({
|
||||
<div className="flex w-full items-center justify-center">
|
||||
<FileInput
|
||||
id="survey-bg-file-input"
|
||||
allowedFileExtensions={["png", "jpeg", "jpg", "webp"]}
|
||||
allowedFileExtensions={["png", "jpeg", "jpg", "webp", "heic"]}
|
||||
environmentId={environmentId}
|
||||
onFileUpload={(url: string[]) => {
|
||||
if (url.length > 0) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { LogicEditorActions } from "@/modules/survey/editor/components/logic-editor-actions";
|
||||
import { LogicEditorConditions } from "@/modules/survey/editor/components/logic-editor-conditions";
|
||||
import { getQuestionIconMap } from "@/modules/survey/lib/questions";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -13,7 +14,6 @@ import { useTranslate } from "@tolgee/react";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import { ReactElement, useMemo } from "react";
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { getQuestionIconMap } from "@formbricks/lib/utils/questions";
|
||||
import { TSurvey, TSurveyLogic, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
|
||||
interface LogicEditorProps {
|
||||
|
||||
@@ -133,7 +133,7 @@ export const PictureSelectionForm = ({
|
||||
<div className="mt-3 flex w-full items-center justify-center">
|
||||
<FileInput
|
||||
id="choices-file-input"
|
||||
allowedFileExtensions={["png", "jpeg", "jpg", "webp"]}
|
||||
allowedFileExtensions={["png", "jpeg", "jpg", "webp", "heic"]}
|
||||
environmentId={environmentId}
|
||||
onFileUpload={handleFileInputChanges}
|
||||
fileUrl={question?.choices?.map((choice) => choice.imageUrl)}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { PictureSelectionForm } from "@/modules/survey/editor/components/picture
|
||||
import { RankingQuestionForm } from "@/modules/survey/editor/components/ranking-question-form";
|
||||
import { RatingQuestionForm } from "@/modules/survey/editor/components/rating-question-form";
|
||||
import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
|
||||
import { getQuestionIconMap, getTSurveyQuestionTypeEnumName } from "@/modules/survey/lib/questions";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { Switch } from "@/modules/ui/components/switch";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
@@ -29,7 +30,6 @@ import { useTranslate } from "@tolgee/react";
|
||||
import { ChevronDownIcon, ChevronRightIcon, GripIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { getQuestionIconMap, getTSurveyQuestionTypeEnumName } from "@formbricks/lib/utils/questions";
|
||||
import { recallToHeadline } from "@formbricks/lib/utils/recall";
|
||||
import {
|
||||
TI18nString,
|
||||
|
||||
@@ -205,6 +205,10 @@ export const ResponseOptionsCard = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleHideBackButtonToggle = () => {
|
||||
setLocalSurvey({ ...localSurvey, isBackButtonHidden: !localSurvey.isBackButtonHidden });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!!localSurvey.surveyClosedMessage) {
|
||||
setSurveyClosedMessage({
|
||||
@@ -515,6 +519,13 @@ export const ResponseOptionsCard = ({
|
||||
</AdvancedOptionToggle>
|
||||
</>
|
||||
)}
|
||||
<AdvancedOptionToggle
|
||||
htmlId="hideBackButton"
|
||||
isChecked={localSurvey.isBackButtonHidden}
|
||||
onToggle={handleHideBackButtonToggle}
|
||||
title={t("environments.surveys.edit.hide_back_button")}
|
||||
description={t("environments.surveys.edit.hide_back_button_description")}
|
||||
/>
|
||||
</div>
|
||||
</Collapsible.CollapsibleContent>
|
||||
</Collapsible.Root>
|
||||
|
||||
@@ -29,18 +29,21 @@ export const SurveyPlacementCard = ({
|
||||
const { projectOverwrites } = localSurvey ?? {};
|
||||
const { placement, clickOutsideClose, darkOverlay } = projectOverwrites ?? {};
|
||||
|
||||
const setProjectOverwrites = (projectOverwrites: TSurveyProjectOverwrites) => {
|
||||
const setProjectOverwrites = (projectOverwrites: TSurveyProjectOverwrites | null) => {
|
||||
setLocalSurvey({ ...localSurvey, projectOverwrites: projectOverwrites });
|
||||
};
|
||||
|
||||
const togglePlacement = () => {
|
||||
if (setProjectOverwrites) {
|
||||
setProjectOverwrites({
|
||||
...projectOverwrites,
|
||||
placement: !!placement ? null : "bottomRight",
|
||||
clickOutsideClose: false,
|
||||
darkOverlay: false,
|
||||
});
|
||||
if (!!placement) {
|
||||
setProjectOverwrites(null);
|
||||
} else {
|
||||
setProjectOverwrites({
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: false,
|
||||
darkOverlay: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -479,6 +479,14 @@ export const getLogicRules = (t: TFnType) => {
|
||||
label: t("environments.surveys.edit.does_not_end_with"),
|
||||
value: ZSurveyLogicConditionsOperator.Enum.doesNotEndWith,
|
||||
},
|
||||
{
|
||||
label: t("environments.surveys.edit.is_set"),
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isSet,
|
||||
},
|
||||
{
|
||||
label: t("environments.surveys.edit.is_not_set"),
|
||||
value: ZSurveyLogicConditionsOperator.Enum.isNotSet,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { TriggerUpdate } from "@/modules/survey/editor/types/survey-trigger";
|
||||
import { getQuestionTypes } from "@/modules/survey/lib/questions";
|
||||
import { TComboboxGroupedOption, TComboboxOption } from "@/modules/ui/components/input-combo-box";
|
||||
import { ActionClass } from "@prisma/client";
|
||||
import { TFnType } from "@tolgee/react";
|
||||
@@ -7,7 +8,6 @@ import { HTMLInputTypeAttribute } from "react";
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { surveyCache } from "@formbricks/lib/survey/cache";
|
||||
import { isConditionGroup } from "@formbricks/lib/surveyLogic/utils";
|
||||
import { getQuestionTypes } from "@formbricks/lib/utils/questions";
|
||||
import { recallToHeadline } from "@formbricks/lib/utils/recall";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import {
|
||||
@@ -216,6 +216,8 @@ export const getMatchValueProps = (
|
||||
"isPartiallySubmitted",
|
||||
"isSkipped",
|
||||
"isSubmitted",
|
||||
"isSet",
|
||||
"isNotSet",
|
||||
].includes(condition.operator)
|
||||
) {
|
||||
return { show: false, options: [] };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contacts";
|
||||
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
|
||||
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
|
||||
import { getIsContactsEnabled, getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ZCreateSurveyFollowUpFormSchema,
|
||||
} from "@/modules/survey/editor/types/survey-follow-up";
|
||||
import FollowUpActionMultiEmailInput from "@/modules/survey/follow-ups/components/follow-up-action-multi-email-input";
|
||||
import { getQuestionIconMap } from "@/modules/survey/lib/questions";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Checkbox } from "@/modules/ui/components/checkbox";
|
||||
import { Editor } from "@/modules/ui/components/editor";
|
||||
@@ -38,7 +39,6 @@ import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { TSurveyFollowUpAction, TSurveyFollowUpTrigger } from "@formbricks/database/types/survey-follow-up";
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { getQuestionIconMap } from "@formbricks/lib/utils/questions";
|
||||
import { recallToHeadline } from "@formbricks/lib/utils/recall";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
StarIcon,
|
||||
} from "lucide-react";
|
||||
import type { JSX } from "react";
|
||||
import { replaceQuestionPresetPlaceholders } from "@formbricks/lib/utils/templates";
|
||||
import {
|
||||
TSurveyQuestionTypeEnum as QuestionId,
|
||||
TSurveyAddressQuestion,
|
||||
@@ -38,7 +39,6 @@ import {
|
||||
TSurveyRankingQuestion,
|
||||
TSurveyRatingQuestion,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { replaceQuestionPresetPlaceholders } from "./templates";
|
||||
|
||||
export type TQuestion = {
|
||||
id: string;
|
||||
@@ -41,6 +41,7 @@ export const selectSurvey = {
|
||||
pin: true,
|
||||
resultShareKey: true,
|
||||
showLanguageSwitch: true,
|
||||
isBackButtonHidden: true,
|
||||
languages: {
|
||||
select: {
|
||||
default: true,
|
||||
|
||||
@@ -6,19 +6,11 @@ import { VerifyEmail } from "@/modules/survey/link/components/verify-email";
|
||||
import { getPrefillValue } from "@/modules/survey/link/lib/utils";
|
||||
import { SurveyInline } from "@/modules/ui/components/survey";
|
||||
import { Project, Response } from "@prisma/client";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { FormbricksAPI } from "@formbricks/api";
|
||||
import { ResponseQueue } from "@formbricks/lib/responseQueue";
|
||||
import { SurveyState } from "@formbricks/lib/surveyState";
|
||||
import { TJsFileUploadParams } from "@formbricks/types/js";
|
||||
import { TResponseData, TResponseHiddenFieldValue, TResponseUpdate } from "@formbricks/types/responses";
|
||||
import { TUploadFileConfig } from "@formbricks/types/storage";
|
||||
import { TResponseData, TResponseHiddenFieldValue } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
let setIsError = (_: boolean) => {};
|
||||
let setIsResponseSendingFinished = (_: boolean) => {};
|
||||
let setQuestionId = (_: string) => {};
|
||||
let setResponseData = (_: TResponseData) => {};
|
||||
|
||||
@@ -57,15 +49,10 @@ export const LinkSurvey = ({
|
||||
locale,
|
||||
isPreview,
|
||||
}: LinkSurveyProps) => {
|
||||
const { t } = useTranslate();
|
||||
const responseId = singleUseResponse?.id;
|
||||
const searchParams = useSearchParams();
|
||||
const skipPrefilled = searchParams.get("skipPrefilled") === "true";
|
||||
const sourceParam = searchParams.get("source");
|
||||
const suId = searchParams.get("suId");
|
||||
const defaultLanguageCode = survey.languages.find((surveyLanguage) => {
|
||||
return surveyLanguage.default;
|
||||
})?.language.code;
|
||||
|
||||
const startAt = searchParams.get("startAt");
|
||||
const isStartAtValid = useMemo(() => {
|
||||
@@ -84,32 +71,8 @@ export const LinkSurvey = ({
|
||||
return isValid;
|
||||
}, [survey, startAt]);
|
||||
|
||||
// pass in the responseId if the survey is a single use survey, ensures survey state is updated with the responseId
|
||||
let surveyState = useMemo(() => {
|
||||
return new SurveyState(survey.id, singleUseId, responseId);
|
||||
}, [survey.id, singleUseId, responseId]);
|
||||
|
||||
const prefillValue = getPrefillValue(survey, searchParams, languageCode);
|
||||
|
||||
const responseQueue = useMemo(
|
||||
() =>
|
||||
new ResponseQueue(
|
||||
{
|
||||
apiHost: webAppUrl,
|
||||
environmentId: survey.environmentId,
|
||||
retryAttempts: 2,
|
||||
onResponseSendingFailed: () => {
|
||||
setIsError(true);
|
||||
},
|
||||
onResponseSendingFinished: () => {
|
||||
// when response of current question is processed successfully
|
||||
setIsResponseSendingFinished(true);
|
||||
},
|
||||
},
|
||||
surveyState
|
||||
),
|
||||
[webAppUrl, survey.environmentId, surveyState]
|
||||
);
|
||||
const [autoFocus, setAutofocus] = useState(false);
|
||||
const hasFinishedSingleUseResponse = useMemo(() => {
|
||||
if (singleUseResponse?.finished) {
|
||||
@@ -148,11 +111,7 @@ export const LinkSurvey = ({
|
||||
}
|
||||
}, [survey.isVerifyEmailEnabled, verifiedEmail]);
|
||||
|
||||
useEffect(() => {
|
||||
responseQueue.updateSurveyState(surveyState);
|
||||
}, [responseQueue, surveyState]);
|
||||
|
||||
if (!surveyState.isResponseFinished() && hasFinishedSingleUseResponse) {
|
||||
if (hasFinishedSingleUseResponse) {
|
||||
return <SurveyLinkUsed singleUseMessage={survey.singleUse} />;
|
||||
}
|
||||
|
||||
@@ -211,81 +170,14 @@ export const LinkSurvey = ({
|
||||
PRIVACY_URL={PRIVACY_URL}
|
||||
isBrandingEnabled={project.linkSurveyBranding}>
|
||||
<SurveyInline
|
||||
apiHost={!isPreview ? webAppUrl : undefined}
|
||||
environmentId={!isPreview ? survey.environmentId : undefined}
|
||||
survey={survey}
|
||||
styling={determineStyling()}
|
||||
languageCode={languageCode}
|
||||
isBrandingEnabled={project.linkSurveyBranding}
|
||||
shouldResetQuestionId={false}
|
||||
getSetIsError={(f: (value: boolean) => void) => {
|
||||
setIsError = f;
|
||||
}}
|
||||
getSetIsResponseSendingFinished={
|
||||
!isPreview
|
||||
? (f: (value: boolean) => void) => {
|
||||
setIsResponseSendingFinished = f;
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onRetry={() => {
|
||||
setIsError(false);
|
||||
void responseQueue.processQueue();
|
||||
}}
|
||||
onDisplay={() => {
|
||||
if (!isPreview) {
|
||||
void (async () => {
|
||||
const api = new FormbricksAPI({
|
||||
apiHost: webAppUrl,
|
||||
environmentId: survey.environmentId,
|
||||
});
|
||||
|
||||
const res = await api.client.display.create({
|
||||
surveyId: survey.id,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(t("s.could_not_create_display"));
|
||||
}
|
||||
|
||||
const { id } = res.data;
|
||||
|
||||
surveyState.updateDisplayId(id);
|
||||
responseQueue.updateSurveyState(surveyState);
|
||||
})();
|
||||
}
|
||||
}}
|
||||
onResponse={(responseUpdate: TResponseUpdate) => {
|
||||
!isPreview &&
|
||||
responseQueue.add({
|
||||
data: {
|
||||
...responseUpdate.data,
|
||||
...hiddenFieldsRecord,
|
||||
...getVerifiedEmail,
|
||||
},
|
||||
ttc: responseUpdate.ttc,
|
||||
finished: responseUpdate.finished,
|
||||
endingId: responseUpdate.endingId,
|
||||
language:
|
||||
responseUpdate.language === "default" && defaultLanguageCode
|
||||
? defaultLanguageCode
|
||||
: responseUpdate.language,
|
||||
meta: {
|
||||
url: window.location.href,
|
||||
source: typeof sourceParam === "string" ? sourceParam : "",
|
||||
},
|
||||
variables: responseUpdate.variables,
|
||||
displayId: surveyState.displayId,
|
||||
...(Object.keys(hiddenFieldsRecord).length > 0 && { hiddenFields: hiddenFieldsRecord }),
|
||||
});
|
||||
}}
|
||||
onFileUpload={async (file: TJsFileUploadParams["file"], params: TUploadFileConfig) => {
|
||||
const api = new FormbricksAPI({
|
||||
apiHost: webAppUrl,
|
||||
environmentId: survey.environmentId,
|
||||
});
|
||||
|
||||
const uploadedUrl = await api.client.storage.uploadFile(file, params);
|
||||
return uploadedUrl;
|
||||
}}
|
||||
onFileUpload={isPreview ? async (file) => `https://formbricks.com/${file.name}` : undefined}
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus -- need it as focus behaviour is different in normal surveys and survey preview
|
||||
autoFocus={autoFocus}
|
||||
prefillResponseData={prefillValue}
|
||||
@@ -299,7 +191,13 @@ export const LinkSurvey = ({
|
||||
}}
|
||||
startAtQuestionId={startAt && isStartAtValid ? startAt : undefined}
|
||||
fullSizeCards={isEmbed}
|
||||
hiddenFieldsRecord={hiddenFieldsRecord}
|
||||
hiddenFieldsRecord={{
|
||||
...hiddenFieldsRecord,
|
||||
...getVerifiedEmail,
|
||||
}}
|
||||
singleUseId={singleUseId}
|
||||
singleUseResponseId={responseId}
|
||||
getSetIsResponseSendingFinished={(_f: (value: boolean) => void) => {}}
|
||||
/>
|
||||
</LinkSurveyWrapper>
|
||||
);
|
||||
|
||||
@@ -41,4 +41,5 @@ export const getMinimalSurvey = (t: TFnType): TSurvey => ({
|
||||
isSingleResponsePerEmailEnabled: false,
|
||||
variables: [],
|
||||
followUps: [],
|
||||
isBackButtonHidden: false,
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { getQuestionIconMap } from "@/modules/survey/lib/questions";
|
||||
import { Switch } from "@/modules/ui/components/switch";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
@@ -8,7 +9,6 @@ import { useTranslate } from "@tolgee/react";
|
||||
import { capitalize } from "lodash";
|
||||
import { GripVertical } from "lucide-react";
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { getQuestionIconMap } from "@formbricks/lib/utils/questions";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
interface DataTableSettingsModalItemProps<T> {
|
||||
|
||||
@@ -68,7 +68,7 @@ export const FileInput = ({
|
||||
toast.error(t("common.only_one_file_allowed"));
|
||||
}
|
||||
|
||||
const allowedFiles = getAllowedFiles(files, allowedFileExtensions, maxSizeInMB);
|
||||
const allowedFiles = await getAllowedFiles(files, allowedFileExtensions, maxSizeInMB);
|
||||
|
||||
if (allowedFiles.length === 0) {
|
||||
return;
|
||||
@@ -137,7 +137,7 @@ export const FileInput = ({
|
||||
};
|
||||
|
||||
const handleUploadMore = async (files: File[]) => {
|
||||
const allowedFiles = getAllowedFiles(files, allowedFileExtensions, maxSizeInMB);
|
||||
const allowedFiles = await getAllowedFiles(files, allowedFileExtensions, maxSizeInMB);
|
||||
if (allowedFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
29
apps/web/modules/ui/components/file-input/lib/actions.ts
Normal file
29
apps/web/modules/ui/components/file-input/lib/actions.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
"use server";
|
||||
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { z } from "zod";
|
||||
|
||||
const ZConvertHeicToJpegInput = z.object({
|
||||
file: z.instanceof(File),
|
||||
});
|
||||
|
||||
export const convertHeicToJpegAction = authenticatedActionClient
|
||||
.schema(ZConvertHeicToJpegInput)
|
||||
.action(async ({ parsedInput }) => {
|
||||
if (!parsedInput.file || !parsedInput.file.name.endsWith(".heic")) return parsedInput.file;
|
||||
|
||||
const convert = (await import("heic-convert")).default;
|
||||
|
||||
const arrayBuffer = await parsedInput.file.arrayBuffer();
|
||||
const nodeBuffer = Buffer.from(arrayBuffer) as unknown as ArrayBufferLike;
|
||||
|
||||
const convertedBuffer = await convert({
|
||||
buffer: nodeBuffer,
|
||||
format: "JPEG",
|
||||
quality: 0.9,
|
||||
});
|
||||
|
||||
return new File([convertedBuffer], parsedInput.file.name.replace(/\.heic$/, ".jpg"), {
|
||||
type: "image/jpeg",
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { toast } from "react-hot-toast";
|
||||
import { TAllowedFileExtension } from "@formbricks/types/common";
|
||||
import { convertHeicToJpegAction } from "./actions";
|
||||
|
||||
export const uploadFile = async (
|
||||
file: File | Blob,
|
||||
@@ -15,8 +16,6 @@ export const uploadFile = async (
|
||||
|
||||
const fileBuffer = await file.arrayBuffer();
|
||||
|
||||
// check the file size
|
||||
|
||||
const bufferBytes = fileBuffer.byteLength;
|
||||
const bufferKB = bufferBytes / 1024;
|
||||
|
||||
@@ -74,7 +73,6 @@ export const uploadFile = async (
|
||||
});
|
||||
}
|
||||
|
||||
// Add the actual file to be uploaded
|
||||
formData.append("file", file);
|
||||
|
||||
const uploadResponse = await fetch(signedUrl, {
|
||||
@@ -96,34 +94,63 @@ export const uploadFile = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const getAllowedFiles = (
|
||||
const isFileSizeExceed = (fileSizeInMB: number, maxSizeInMB?: number) => {
|
||||
if (maxSizeInMB && fileSizeInMB > maxSizeInMB) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const getAllowedFiles = async (
|
||||
files: File[],
|
||||
allowedFileExtensions: string[],
|
||||
maxSizeInMB?: number
|
||||
): File[] => {
|
||||
): Promise<File[]> => {
|
||||
const sizeExceedFiles: string[] = [];
|
||||
const unsupportedExtensionFiles: string[] = [];
|
||||
const convertedFiles: File[] = [];
|
||||
|
||||
const allowedFiles = files.filter((file) => {
|
||||
for (const file of files) {
|
||||
if (!file || !file.type) {
|
||||
return false;
|
||||
continue;
|
||||
}
|
||||
|
||||
const extension = file.name.split(".").pop();
|
||||
const fileSizeInMB = file.size / 1000000; // Kb -> Mb
|
||||
const extension = file.name.split(".").pop()?.toLowerCase();
|
||||
const fileSizeInMB = file.size / 1000000;
|
||||
|
||||
if (!allowedFileExtensions.includes(extension as TAllowedFileExtension)) {
|
||||
unsupportedExtensionFiles.push(file.name);
|
||||
return false; // Exclude file if extension not allowed
|
||||
} else if (maxSizeInMB && fileSizeInMB > maxSizeInMB) {
|
||||
sizeExceedFiles.push(file.name);
|
||||
return false; // Exclude files larger than the maximum size
|
||||
continue;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
if (isFileSizeExceed(fileSizeInMB, maxSizeInMB)) {
|
||||
sizeExceedFiles.push(file.name);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (extension === "heic") {
|
||||
const convertedFileResponse = await convertHeicToJpegAction({ file });
|
||||
if (!convertedFileResponse?.data) {
|
||||
unsupportedExtensionFiles.push(file.name);
|
||||
continue;
|
||||
} else {
|
||||
const convertedFileSizeInMB = convertedFileResponse.data.size / 1000000;
|
||||
if (isFileSizeExceed(convertedFileSizeInMB, maxSizeInMB)) {
|
||||
sizeExceedFiles.push(file.name);
|
||||
continue;
|
||||
}
|
||||
|
||||
const convertedFile = new File([convertedFileResponse.data], file.name.replace(/\.heic$/, ".jpg"), {
|
||||
type: "image/jpeg",
|
||||
});
|
||||
convertedFiles.push(convertedFile);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
convertedFiles.push(file);
|
||||
}
|
||||
|
||||
// Constructing toast messages based on the issues found
|
||||
let toastMessage = "";
|
||||
if (sizeExceedFiles.length > 0) {
|
||||
toastMessage += `Files exceeding size limit (${maxSizeInMB} MB): ${sizeExceedFiles.join(", ")}. `;
|
||||
@@ -134,7 +161,7 @@ export const getAllowedFiles = (
|
||||
if (toastMessage) {
|
||||
toast.error(toastMessage);
|
||||
}
|
||||
return allowedFiles;
|
||||
return convertedFiles;
|
||||
};
|
||||
|
||||
export const checkForYoutubePrivacyMode = (url: string): boolean => {
|
||||
|
||||
@@ -229,7 +229,7 @@ export const InputCombobox = ({
|
||||
className="min-w-0 rounded-none border-0 border-r border-slate-300 bg-white focus:border-slate-400"
|
||||
{...inputProps}
|
||||
id={`${id}-input`}
|
||||
value={localValue as string | number}
|
||||
value={localValue ?? undefined}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { SurveyInlineProps, SurveyModalProps } from "@formbricks/types/formbricks-surveys";
|
||||
import { SurveyContainerProps } from "@formbricks/types/formbricks-surveys";
|
||||
|
||||
const createContainerId = () => `formbricks-survey-container`;
|
||||
declare global {
|
||||
interface Window {
|
||||
formbricksSurveys: {
|
||||
renderSurveyInline: (props: SurveyInlineProps) => void;
|
||||
renderSurveyModal: (props: SurveyModalProps) => void;
|
||||
renderSurveyInline: (props: SurveyContainerProps) => void;
|
||||
renderSurveyModal: (props: SurveyContainerProps) => void;
|
||||
renderSurvey: (props: SurveyContainerProps) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const SurveyInline = (props: Omit<SurveyInlineProps, "containerId">) => {
|
||||
export const SurveyInline = (props: Omit<SurveyContainerProps, "containerId">) => {
|
||||
const containerId = useMemo(() => createContainerId(), []);
|
||||
const renderInline = useCallback(
|
||||
() => window.formbricksSurveys.renderSurveyInline({ ...props, containerId }),
|
||||
() => window.formbricksSurveys.renderSurvey({ ...props, containerId, mode: "inline" }),
|
||||
[containerId, props]
|
||||
);
|
||||
const [isScriptLoaded, setIsScriptLoaded] = useState(false);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@formbricks/web",
|
||||
"version": "3.2.0",
|
||||
"version": "3.3.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"clean": "rimraf .turbo node_modules .next",
|
||||
@@ -15,6 +15,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/azure": "1.1.9",
|
||||
"@boxyhq/saml-jackson": "1.37.1",
|
||||
"@dnd-kit/core": "6.3.1",
|
||||
"@dnd-kit/modifiers": "9.0.0",
|
||||
"@dnd-kit/sortable": "10.0.0",
|
||||
@@ -83,6 +84,7 @@
|
||||
"file-loader": "6.2.0",
|
||||
"framer-motion": "11.15.0",
|
||||
"googleapis": "144.0.0",
|
||||
"heic-convert": "2.1.0",
|
||||
"https-proxy-agent": "7.0.6",
|
||||
"jiti": "2.4.1",
|
||||
"jsonwebtoken": "9.0.2",
|
||||
@@ -129,6 +131,7 @@
|
||||
"@formbricks/eslint-config": "workspace:*",
|
||||
"@neshca/cache-handler": "1.9.0",
|
||||
"@types/bcryptjs": "2.4.6",
|
||||
"@types/heic-convert": "2.1.0",
|
||||
"@types/lodash": "4.17.13",
|
||||
"@types/markdown-it": "14.1.2",
|
||||
"@types/nodemailer": "6.4.17",
|
||||
|
||||
@@ -29,6 +29,7 @@ test.describe("JS Package Test", async () => {
|
||||
let server: http.Server;
|
||||
let environmentId: string;
|
||||
|
||||
test.setTimeout(3 * 60 * 1000);
|
||||
test.beforeAll(async () => {
|
||||
// Create a simple HTTP server
|
||||
server = http.createServer((_, res) => {
|
||||
@@ -124,6 +125,7 @@ test.describe("JS Package Test", async () => {
|
||||
|
||||
await page.getByTestId("loading-spinner").waitFor({ state: "hidden" });
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
// Validate displays and response
|
||||
await page.goto("/");
|
||||
@@ -131,7 +133,7 @@ test.describe("JS Package Test", async () => {
|
||||
await page.getByRole("link", { name: "product Market Fit (Superhuman)" }).click();
|
||||
await page.waitForSelector("text=Responses");
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.waitForTimeout(2000);
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
const impressionsCount = await page.getByRole("button", { name: "Impressions" }).innerText();
|
||||
expect(impressionsCount).toEqual("Impressions\n\n1");
|
||||
|
||||
@@ -3,9 +3,18 @@
|
||||
import { TolgeeProvider, TolgeeStaticData } from "@tolgee/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { branchName } from "../../../branch.json";
|
||||
import { TolgeeBase } from "./shared";
|
||||
|
||||
// Try to import branch.json, but handle the case where it doesn't exist
|
||||
let branchName: string | undefined;
|
||||
try {
|
||||
const branch = require("../../../branch.json");
|
||||
branchName = branch.branchName;
|
||||
} catch (e) {
|
||||
// File doesn't exist in production, so we'll use undefined
|
||||
branchName = undefined;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
language: string;
|
||||
staticData: TolgeeStaticData;
|
||||
@@ -13,7 +22,7 @@ type Props = {
|
||||
};
|
||||
|
||||
const tolgee = TolgeeBase().init({
|
||||
tagNewKeys: [`draft: ${branchName}`],
|
||||
tagNewKeys: branchName ? [`draft:${branchName}`] : [],
|
||||
});
|
||||
|
||||
export const TolgeeNextProvider = ({ language, staticData, children }: Props) => {
|
||||
@@ -25,6 +34,7 @@ export const TolgeeNextProvider = ({ language, staticData, children }: Props) =>
|
||||
router.refresh();
|
||||
});
|
||||
return () => unsubscribe();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tolgee, router]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import { createServerInstance } from "@tolgee/react/server";
|
||||
import { branchName } from "../../../branch.json";
|
||||
import { getLocale } from "./language";
|
||||
import { TolgeeBase } from "./shared";
|
||||
|
||||
// Try to import branch.json, but handle the case where it doesn't exist
|
||||
let branchName: string | undefined;
|
||||
try {
|
||||
const branch = require("../../../branch.json");
|
||||
branchName = branch.branchName;
|
||||
} catch (e) {
|
||||
// File doesn't exist in production, so we'll use undefined
|
||||
branchName = undefined;
|
||||
}
|
||||
|
||||
export const { getTolgee, getTranslate, T } = createServerInstance({
|
||||
getLocale: getLocale,
|
||||
createTolgee: async (language) => {
|
||||
return TolgeeBase().init({
|
||||
tagNewKeys: [`draft: ${branchName}`],
|
||||
tagNewKeys: branchName ? [`draft:${branchName}`] : [],
|
||||
observerOptions: {
|
||||
fullKeyEncode: true,
|
||||
},
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{ "branchName": "main" }
|
||||
@@ -50,9 +50,6 @@ x-environment: &environment
|
||||
|
||||
############################################## OPTIONAL (APP CONFIGURATION) ##############################################
|
||||
|
||||
# Set the below value if you have and want to use a custom URL for the links created by the Link Shortener
|
||||
# SHORT_URL_BASE:
|
||||
|
||||
# Set the below to 0 to enable Email Verification for new signups (will required Email Configuration)
|
||||
EMAIL_VERIFICATION_DISABLED: 1
|
||||
|
||||
@@ -114,6 +111,9 @@ x-environment: &environment
|
||||
# OIDC_DISPLAY_NAME:
|
||||
# OIDC_SIGNING_ALGORITHM:
|
||||
|
||||
# Set the below to SAML Provider if you want to enable SAML
|
||||
# SAML_DATABASE_URL: "postgresql://postgres:postgres@postgres:5432/formbricks-saml?sslmode=disable"
|
||||
|
||||
########################################## OPTIONAL (THIRD PARTY INTEGRATIONS) ###########################################
|
||||
|
||||
# Oauth credentials for Notion Integration
|
||||
@@ -188,9 +188,11 @@ services:
|
||||
- 3000:3000
|
||||
volumes:
|
||||
- uploads:/home/nextjs/apps/web/uploads/
|
||||
- ./saml-connection:/home/nextjs/apps/web/saml-connection
|
||||
<<: *environment
|
||||
|
||||
volumes:
|
||||
postgres:
|
||||
driver: local
|
||||
uploads:
|
||||
driver: local
|
||||
|
||||
44
docs/api-reference/generate-key.mdx
Normal file
44
docs/api-reference/generate-key.mdx
Normal file
@@ -0,0 +1,44 @@
|
||||
---
|
||||
title: "Generate API Key"
|
||||
icon: "key"
|
||||
description: "Here is how you can generate an API key which gives you full access to the Formbricks Management API. Keep it safe!"
|
||||
---
|
||||
<Note>
|
||||
As of now, API keys are located in the Project Configuration page. We are moving them to the Organization Settings page in the upcoming release. For you, nothing will change.
|
||||
</Note>
|
||||
|
||||
## Generate API key
|
||||
|
||||
<Steps>
|
||||
<Step title="Navigate to 'Configuration'">
|
||||
Go to the Configuration page of your project:
|
||||

|
||||
</Step>
|
||||
|
||||
<Step title="Access 'API Keys' tab">
|
||||
Click on the **API Keys** tab.
|
||||
</Step>
|
||||
|
||||
<Step title="Generate key">
|
||||
Decide if you want to generate a key for the development or production environment. If you want to switch environemnts you can do so in the top right corner. Click on the corresponding button.
|
||||
</Step>
|
||||
|
||||
<Step title="Add a label to your key">
|
||||
Add a label to your key to help you identify it.
|
||||

|
||||
</Step>
|
||||
|
||||
<Step title="Copy your key">
|
||||
Copy the API key and save it in a secure location. You won't be able to see it again.
|
||||
<Note>
|
||||
Store API key safely! Anyone who has your API key has full control over your
|
||||
account. For security reasons, you cannot view the API key again.
|
||||
</Note>
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Delete API key
|
||||
|
||||
- On **Configuration** > **API Keys** page, find the key you wish to revoke and select “Delete”.
|
||||
|
||||
- Your API key will stop working immediately.
|
||||
@@ -6952,7 +6952,7 @@
|
||||
},
|
||||
"/api/v1/management/surveys/{surveyId}/singleUseIds": {
|
||||
"get": {
|
||||
"description": "Generates multiple single use survey links for a survey based on its id. Count of links can be controlled using the limit query param(min: 1, max: 5000, default: 10).",
|
||||
"description": "Generates multiple single use survey links for a survey based on its id.",
|
||||
"parameters": [
|
||||
{
|
||||
"example": "{{apiKey}}",
|
||||
@@ -6970,6 +6970,21 @@
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Number of links to generate.",
|
||||
"example": 10,
|
||||
"in": "query",
|
||||
"name": "limit",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"default": 10,
|
||||
"exclusiveMaximum": false,
|
||||
"exclusiveMinimum": false,
|
||||
"maximum": 5000,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -7710,7 +7725,7 @@
|
||||
}
|
||||
},
|
||||
"summary": "Health Check",
|
||||
"tags": ["default"]
|
||||
"tags": ["Health"]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -5,9 +5,8 @@ description: "
|
||||
Formbricks provides two APIs: the Public Client API for frontend survey interactions and the Management API for backend management tasks."
|
||||
---
|
||||
|
||||
<Info>
|
||||
View our [API Documentation](/api-reference) in more than 30 frameworks and languages.
|
||||
</Info>
|
||||
Formbricks offers two types of APIs: the **Public Client API** and the **Management API**. Each API serves a different purpose, has *different* authentication requirements, and provides access to different data and settings.
|
||||
|
||||
|
||||
## Public Client API
|
||||
|
||||
@@ -17,7 +16,7 @@ We currently have the following Client API methods exposed and below is their do
|
||||
|
||||
- [Displays API](/api-reference/client-api->-display/create-display) - Mark a survey as displayed or link a display to a response for a person.
|
||||
|
||||
- [People API](/api-reference/client-api->-people/create-person) - Create & Update a Person (e.g., attributes, email, userId, etc.)
|
||||
- [Contacts API](/api-reference/client-api->-contacts/update-contact-attributes) - Update contact attributes.
|
||||
|
||||
- [Responses API](/api-reference/client-api->-response/create-response) - Create & Update a Response for a Survey.
|
||||
|
||||
@@ -41,88 +40,7 @@ We currently have the following Management API methods exposed and below is thei
|
||||
|
||||
- [Webhook API](/api-reference/management-api->-webhook/get-all-webhooks) - List, Create, and Delete Webhooks
|
||||
|
||||
## How to Generate an API key
|
||||
|
||||
API requests require a personal API key for authorization. This API key gives you the same rights as if you were logged in at Formbricks UI - **keep it private!**
|
||||
|
||||
- Go to your settings on [Formbricks UI](https://app.formbricks.com).
|
||||
|
||||
- Go to page “API keys”
|
||||
|
||||

|
||||
|
||||
- Create a key for the development or production environment.
|
||||
|
||||
- Copy the key immediately. You won’t be able to see it again.
|
||||
|
||||

|
||||
|
||||
<Note>
|
||||
**Store API key safely! Anyone who has your API key has full control over your
|
||||
account. For security reasons, you cannot view the API key again.**
|
||||
</Note>
|
||||
|
||||
## Test your API Key
|
||||
|
||||
Hit the below request to verify that you are authenticated with your API Key and the server is responding.
|
||||
|
||||
### Get My Profile
|
||||
|
||||
Get the project details and environment type of your account.
|
||||
|
||||
### Mandatory Headers
|
||||
|
||||
| Name | x-Api-Key |
|
||||
| --------------- | ------------------------ |
|
||||
| **Type** | string |
|
||||
| **Description** | Your Formbricks API key. |
|
||||
|
||||
### Request
|
||||
|
||||
```bash cURL
|
||||
GET - /api/v1/me
|
||||
|
||||
curl --location \
|
||||
'https://app.formbricks.com/api/v1/me' \
|
||||
--header \
|
||||
'x-api-key: <your-api-key>'
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
|
||||
```bash 200 (Success)
|
||||
{
|
||||
"id": "cll2m30r70004mx0huqkitgqv",
|
||||
"createdAt": "2023-08-08T18:04:59.922Z",
|
||||
"updatedAt": "2023-08-08T18:04:59.922Z",
|
||||
"type": "production",
|
||||
"project": {
|
||||
"id": "cll2m30r60003mx0hnemjfckr",
|
||||
"name": "My Project"
|
||||
},
|
||||
"appSetupCompleted": false,
|
||||
"websiteSetupCompleted": false,
|
||||
}
|
||||
```
|
||||
|
||||
```bash 401 (Not Authenticated)
|
||||
Not authenticated
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
|
||||
### Delete a personal API key
|
||||
|
||||
- Go to settings on [app.formbricks.com](https://app.formbricks.com/).
|
||||
|
||||
- Go to the page “API keys”.
|
||||
|
||||
- Find the key you wish to revoke and select “Delete”.
|
||||
|
||||
- Your API key will stop working immediately.
|
||||
|
||||
---
|
||||
|
||||
|
||||
50
docs/api-reference/test-key.mdx
Normal file
50
docs/api-reference/test-key.mdx
Normal file
@@ -0,0 +1,50 @@
|
||||
---
|
||||
title: "Test API Key"
|
||||
icon: "message-check"
|
||||
description: "Here is how you can test your API key to make sure it is working."
|
||||
---
|
||||
|
||||
To test if your API key is working, you can use the following request:
|
||||
|
||||
### Mandatory Headers
|
||||
|
||||
| Name | x-Api-Key |
|
||||
| --------------- | ------------------------ |
|
||||
| **Type** | string |
|
||||
| **Description** | Your Formbricks API key. |
|
||||
|
||||
### Request
|
||||
|
||||
```bash cURL
|
||||
GET - /api/v1/me
|
||||
|
||||
curl --location \
|
||||
'https://app.formbricks.com/api/v1/me' \
|
||||
--header \
|
||||
'x-api-key: <your-api-key>'
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
|
||||
```bash 200 (Success)
|
||||
{
|
||||
"id": "cll2m30r70004mx0huqkitgqv",
|
||||
"createdAt": "2023-08-08T18:04:59.922Z",
|
||||
"updatedAt": "2023-08-08T18:04:59.922Z",
|
||||
"type": "production",
|
||||
"project": {
|
||||
"id": "cll2m30r60003mx0hnemjfckr",
|
||||
"name": "My Project"
|
||||
},
|
||||
"appSetupCompleted": false,
|
||||
"websiteSetupCompleted": false,
|
||||
}
|
||||
```
|
||||
|
||||
```bash 401 (Not Authenticated)
|
||||
Not authenticated
|
||||
```
|
||||
</CodeGroup>
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: "API v1.0.0"
|
||||
title: "API v2 Reference (Draft)"
|
||||
icon: "code-compare"
|
||||
---
|
||||
|
||||
1088
docs/api-v2-reference/openapi.yml
Normal file
1088
docs/api-v2-reference/openapi.yml
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user