Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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=
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { GET } from "@/modules/ee/auth/saml/api/authorize/route";
|
||||
|
||||
export { GET };
|
||||
@@ -0,0 +1,3 @@
|
||||
import { POST } from "@/modules/ee/auth/saml/api/callback/route";
|
||||
|
||||
export { POST };
|
||||
@@ -0,0 +1,3 @@
|
||||
import { POST } from "@/modules/ee/auth/saml/api/token/route";
|
||||
|
||||
export { POST };
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" }
|
||||
@@ -111,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
|
||||
@@ -185,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
|
||||
|
||||
|
Before Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 330 KiB |
|
Before Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 20 KiB |
@@ -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": {
|
||||
|
||||