From 6bb6b293ad35dd79a05521cde76b79ae46a13458 Mon Sep 17 00:00:00 2001
From: Javi Aguilar <122741+itsjavi@users.noreply.github.com>
Date: Mon, 20 Jul 2026 11:44:52 +0200
Subject: [PATCH 1/6] fix(surveys): visual a11y polish for the survey player
(#8568)
---
.../modules/survey/link/components/verify-email.tsx | 6 +++++-
.../src/components/elements/file-upload.tsx | 2 +-
packages/survey-ui/src/styles/globals.css | 7 +++++++
.../components/wrappers/cardless-survey-layout.tsx | 12 ++++++++++--
4 files changed, 23 insertions(+), 4 deletions(-)
diff --git a/apps/web/modules/survey/link/components/verify-email.tsx b/apps/web/modules/survey/link/components/verify-email.tsx
index 8a29d77b8f11..8b0b466e6840 100644
--- a/apps/web/modules/survey/link/components/verify-email.tsx
+++ b/apps/web/modules/survey/link/components/verify-email.tsx
@@ -176,7 +176,11 @@ export const VerifyEmail = ({
placeholder="engineering@acme.com"
className="h-10 bg-white"
/>
-
diff --git a/apps/web/modules/ee/unify-feedback/topics-subtopics/components/topics-subtopics-container.tsx b/apps/web/modules/ee/unify-feedback/topics-subtopics/components/topics-subtopics-container.tsx
index 9f62bfb5e440..fc36ff554e59 100644
--- a/apps/web/modules/ee/unify-feedback/topics-subtopics/components/topics-subtopics-container.tsx
+++ b/apps/web/modules/ee/unify-feedback/topics-subtopics/components/topics-subtopics-container.tsx
@@ -5,7 +5,6 @@ import type { TFunction } from "i18next";
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
-import { getV3ApiErrorMessage } from "@/modules/api/lib/v3-client";
import type { TaxonomyNode, TaxonomyRun } from "@/modules/hub/types";
import { Alert, AlertButton, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
@@ -47,7 +46,9 @@ const runFailureMessageFromCode = (code: TaxonomyRun["error_code"], t: TFunction
case "internal_error":
return t("workspace.unify.taxonomy_failure_internal_error");
default:
- return t("workspace.unify.taxonomy_start_failed");
+ // Unknown/absent code (e.g. a future Hub failure code we don't map yet) — a neutral message,
+ // not the wrong-tense "failed to start" (the run did start, then failed).
+ return t("workspace.unify.taxonomy_failure_generation_failed");
}
};
@@ -90,6 +91,7 @@ export const TopicsSubtopicsContainer = ({
const stateQuery = useTaxonomyState({ workspaceId, scope });
const activeTree = stateQuery.data?.activeTree ?? null;
+ const stateUnavailable = stateQuery.data?.unavailable ?? false;
const activeRunId = activeTree?.run.id ?? null;
const runs = useMemo(() => stateQuery.data?.runs ?? [], [stateQuery.data]);
const latestRun = runs[0] ?? null;
@@ -143,12 +145,17 @@ export const TopicsSubtopicsContainer = ({
}, [runStatus, queryClient, workspaceId, scope]);
const isRunning = runningRunId !== null || triggerMutation.isPending;
- // The directory scope always has a target, so generation only depends on write access + no active run.
- const canGenerate = canWrite && !isRunning;
+ // A failed run keeps generation enabled (that is the retry path); only block generation while the
+ // taxonomy service itself is unreachable, since a new run can't succeed then anyway.
+ const serviceUnavailable = fieldsUnavailable || stateUnavailable;
+ const canGenerate = canWrite && !isRunning && !serviceUnavailable;
const hasActiveTree = Boolean(activeTree?.root?.children?.length);
+ // Surface the curated, localized failure message keyed on error_code — but only when there is NO
+ // active tree, so a failed *re*generate doesn't drop an error banner over a taxonomy that still works
+ // (the toast already gives a transient heads-up). The raw Hub `error` string stays server-side (logs).
const runFailure =
- latestRun?.status === "failed"
- ? (latestRun.error ?? runFailureMessageFromCode(latestRun.error_code, t))
+ latestRun?.status === "failed" && !hasActiveTree
+ ? runFailureMessageFromCode(latestRun.error_code, t)
: null;
const handleGenerate = () => {
@@ -161,8 +168,10 @@ export const TopicsSubtopicsContainer = ({
? t("workspace.unify.taxonomy_run_in_progress")
: t("workspace.unify.taxonomy_run_started")
),
- onError: (error) =>
- toast.error(getV3ApiErrorMessage(error, t("workspace.unify.taxonomy_start_failed"))),
+ // A trigger failure comes back as a raw upstream detail (502 / bad_gateway with the Hub's JSON
+ // blob), so show a clean localized message instead of dumping it. The specific failed-run alert
+ // (with the exact reason) still renders once /state refetches; raw detail stays in server logs.
+ onError: () => toast.error(t("workspace.unify.taxonomy_start_failed")),
}
);
};
@@ -172,7 +181,8 @@ export const TopicsSubtopicsContainer = ({
await renameMutation.mutateAsync({ nodeId, label });
toast.success(t("workspace.unify.taxonomy_rename_success"));
} catch (error) {
- toast.error(getV3ApiErrorMessage(error, t("workspace.unify.taxonomy_rename_failed")));
+ // Localized message only — never surface the raw upstream detail (see the trigger toast above).
+ toast.error(t("workspace.unify.taxonomy_rename_failed"));
throw error;
}
};
@@ -203,8 +213,7 @@ export const TopicsSubtopicsContainer = ({
});
}
},
- onError: (error) =>
- toast.error(getV3ApiErrorMessage(error, t("workspace.unify.taxonomy_remove_failed"))),
+ onError: () => toast.error(t("workspace.unify.taxonomy_remove_failed")),
}
);
};
@@ -249,7 +258,7 @@ export const TopicsSubtopicsContainer = ({
isLoadingFields={fieldsQuery.isLoading}
hasActiveTree={hasActiveTree}
canGenerate={canGenerate}
- isGenerating={triggerMutation.isPending}
+ isGenerating={isRunning}
onGenerate={handleGenerate}
canWrite={canWrite}
/>
@@ -258,11 +267,16 @@ export const TopicsSubtopicsContainer = ({
)}
- {fieldsUnavailable && (
-
-
- {fieldsQuery.data?.unavailableMessage ?? t("workspace.unify.taxonomy_fields_unavailable")}
-
+ {serviceUnavailable && (
+
+ {t("workspace.unify.taxonomy_service_unavailable")}
+ {
+ void fieldsQuery.refetch();
+ void stateQuery.refetch();
+ }}>
+ {t("common.retry")}
+
)}
diff --git a/apps/web/modules/ee/unify-feedback/topics-subtopics/hooks/use-trigger-taxonomy-run.ts b/apps/web/modules/ee/unify-feedback/topics-subtopics/hooks/use-trigger-taxonomy-run.ts
index fddb2e6bdebb..68e739257c35 100644
--- a/apps/web/modules/ee/unify-feedback/topics-subtopics/hooks/use-trigger-taxonomy-run.ts
+++ b/apps/web/modules/ee/unify-feedback/topics-subtopics/hooks/use-trigger-taxonomy-run.ts
@@ -5,8 +5,9 @@ import { InvalidInputError } from "@formbricks/types/errors";
import { triggerTaxonomyRun } from "../lib/api-client";
import { type TTaxonomyScopeSelection, taxonomyKeys } from "../lib/query";
-/** Start (or resume) a taxonomy run for the selected scope, then invalidate `state` so the new run
- * surfaces and run-status polling picks it up. */
+/** Start (or resume) a taxonomy run for the selected scope, then refetch `state` whether the run
+ * started or failed to start: on success the new pending run surfaces (and polling picks it up); on
+ * failure the persisted failed run surfaces so its error alert renders instead of only a toast. */
export const useTriggerTaxonomyRun = ({
workspaceId,
scope,
@@ -19,6 +20,6 @@ export const useTriggerTaxonomyRun = ({
}
return triggerTaxonomyRun({ workspaceId, ...scope, fieldLabel: variables.fieldLabel });
},
- onSuccess: () => queryClient.invalidateQueries({ queryKey: taxonomyKeys.state(workspaceId, scope) }),
+ onSettled: () => queryClient.invalidateQueries({ queryKey: taxonomyKeys.state(workspaceId, scope) }),
});
};
From 10d25081c4fc2429232ef6fee09498a807dd2cea Mon Sep 17 00:00:00 2001
From: Johannes <72809645+jobenjada@users.noreply.github.com>
Date: Mon, 20 Jul 2026 13:32:47 +0200
Subject: [PATCH 4/6] docs(unify-feedback): rename "Feedback Directories" to
"Feedback Datasets" (#8584)
Co-authored-by: Claude Opus 4.8
---
docs/docs.json | 6 +++-
.../enterprise-features/dashboards.mdx | 2 +-
.../enterprise-features/unify-feedback.mdx | 2 +-
docs/self-hosting/advanced/license.mdx | 2 +-
docs/unify-feedback/dashboards-charts.mdx | 8 ++---
docs/unify-feedback/feedback-datasets.mdx | 31 +++++++++++++++++++
docs/unify-feedback/feedback-directories.mdx | 31 -------------------
docs/unify-feedback/feedback-records.mdx | 8 ++---
docs/unify-feedback/feedback-sources.mdx | 10 +++---
.../journey-management/close-the-loop.mdx | 2 +-
.../journey-management/detection-signals.mdx | 2 +-
.../map-feedback-to-journeys.mdx | 2 +-
.../journey-management/recurring-patterns.mdx | 2 +-
docs/unify-feedback/overview.mdx | 10 +++---
docs/unify-feedback/topics-subtopics.mdx | 8 ++---
15 files changed, 65 insertions(+), 61 deletions(-)
create mode 100644 docs/unify-feedback/feedback-datasets.mdx
delete mode 100644 docs/unify-feedback/feedback-directories.mdx
diff --git a/docs/docs.json b/docs/docs.json
index 7d0d2d958ba9..c1b18a67d976 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -230,7 +230,7 @@
{
"group": "Unify Feedback Features",
"pages": [
- "unify-feedback/feedback-directories",
+ "unify-feedback/feedback-datasets",
"unify-feedback/feedback-sources",
"unify-feedback/feedback-records",
"unify-feedback/dashboards-charts",
@@ -457,6 +457,10 @@
]
},
"redirects": [
+ {
+ "destination": "/docs/unify-feedback/feedback-datasets",
+ "source": "/docs/unify-feedback/feedback-directories"
+ },
{
"destination": "/docs/platform/introduction",
"source": "/docs/overview/introduction"
diff --git a/docs/self-hosting/advanced/enterprise-features/dashboards.mdx b/docs/self-hosting/advanced/enterprise-features/dashboards.mdx
index cd04bb335bca..c05434baac7c 100644
--- a/docs/self-hosting/advanced/enterprise-features/dashboards.mdx
+++ b/docs/self-hosting/advanced/enterprise-features/dashboards.mdx
@@ -5,6 +5,6 @@ icon: "chart-line"
sidebarTitle: "Dashboards & Charts"
---
-Build Area, Bar, Line, Pie, and Big Number charts on top of any Feedback Directory, then arrange them on dashboards to share with your team.
+Build Area, Bar, Line, Pie, and Big Number charts on top of any Feedback Dataset, then arrange them on dashboards to share with your team.
Read the full guide: [Dashboards & Charts](/unify-feedback/dashboards-charts).
diff --git a/docs/self-hosting/advanced/enterprise-features/unify-feedback.mdx b/docs/self-hosting/advanced/enterprise-features/unify-feedback.mdx
index a7cf490bff98..8bc33a286237 100644
--- a/docs/self-hosting/advanced/enterprise-features/unify-feedback.mdx
+++ b/docs/self-hosting/advanced/enterprise-features/unify-feedback.mdx
@@ -5,6 +5,6 @@ icon: "layer-group"
sidebarTitle: "Unify Feedback"
---
-Unify Feedback brings survey responses, CSV uploads, and API-ingested records into the same normalized model under organization-scoped Feedback Directories. Workspaces can be granted access to specific directories.
+Unify Feedback brings survey responses, CSV uploads, and API-ingested records into the same normalized model under organization-scoped Feedback Datasets. Workspaces can be granted access to specific datasets.
Read the full guide: [Unify Feedback overview](/unify-feedback/overview).
diff --git a/docs/self-hosting/advanced/license.mdx b/docs/self-hosting/advanced/license.mdx
index 44cbc5d2c80d..8e945711a1c1 100644
--- a/docs/self-hosting/advanced/license.mdx
+++ b/docs/self-hosting/advanced/license.mdx
@@ -88,7 +88,7 @@ The Enterprise Edition allows us to fund the development of Formbricks sustainab
| Contact management & segments | ❌ | ✅ |
| Quota Management | ❌ | ✅ |
| Unify Feedback Inbox | ❌ | ✅ |
-| Feedback Directories | ❌ | ✅ |
+| Feedback Datasets | ❌ | ✅ |
| Insights Dashboards | ❌ | ✅ |
| Audit Logs | ❌ | ✅ |
| OIDC SSO (AzureAD, Google, OpenID) | ❌ | ✅ |
diff --git a/docs/unify-feedback/dashboards-charts.mdx b/docs/unify-feedback/dashboards-charts.mdx
index 14e9aa01f8fb..8dda3e8c290f 100644
--- a/docs/unify-feedback/dashboards-charts.mdx
+++ b/docs/unify-feedback/dashboards-charts.mdx
@@ -4,7 +4,7 @@ description: "Visualize Feedback Records and group charts onto shareable dashboa
icon: "chart-line"
---
-Dashboards & Charts let you turn Feedback Records into visual analytics. A **Chart** is a single visualization scoped to one Feedback Directory. A **Dashboard** is a grid of charts you can share with your team.
+Dashboards & Charts let you turn Feedback Records into visual analytics. A **Chart** is a single visualization scoped to one Feedback Dataset. A **Dashboard** is a grid of charts you can share with your team.
## Charts
@@ -24,7 +24,7 @@ You can build a chart in two ways:
### Manual builder
-Pick a Feedback Directory, choose dimensions and measures (count of records, average NPS, ...), apply filters, and select a chart type. Live preview updates as you tweak.
+Pick a Feedback Dataset, choose dimensions and measures (count of records, average NPS, ...), apply filters, and select a chart type. Live preview updates as you tweak.
### AI builder
@@ -51,5 +51,5 @@ From a dashboard you can:
## Requirements
-- A Feedback Directory with records.
-- Workspace access to that directory.
+- A Feedback Dataset with records.
+- Workspace access to that dataset.
diff --git a/docs/unify-feedback/feedback-datasets.mdx b/docs/unify-feedback/feedback-datasets.mdx
new file mode 100644
index 000000000000..037119ef8373
--- /dev/null
+++ b/docs/unify-feedback/feedback-datasets.mdx
@@ -0,0 +1,31 @@
+---
+title: "Feedback Datasets"
+description: "Org-level containers that group related Feedback Records and their sources."
+icon: "folder-tree"
+---
+
+A **Feedback Dataset** is the top-level container for feedback inside an organization. Every Feedback Record belongs to exactly one dataset, and every source writes into a single dataset.
+
+## When to create a new dataset
+
+Create one dataset per logically separate group of feedback. Common patterns:
+
+- **By product line** (e.g. "Mobile devices", "Web apps")
+- **By stakeholder group** (e.g. "Customers", "Employees")
+- **By region** (e.g. "Europe", "North America")
+
+## Workspace access
+
+Datasets live at the **organization** level but are exposed to **workspaces** through an access list. Each workspace can **only access one dataset.**
+
+Manage dataset access from **Settings → Organization → Feedback Datasets**:
+
+- Create new datasets
+- Rename or archive datasets
+- Add or remove workspace access
+
+Only **Owners** and **Managers** can manage datasets. Workspace members see the datasets their workspace has access to inside the Unify section.
+
+## Archiving
+
+Archiving a dataset hides it from default views but does not delete its records. Use it for one-off programs that have ended.
diff --git a/docs/unify-feedback/feedback-directories.mdx b/docs/unify-feedback/feedback-directories.mdx
deleted file mode 100644
index 9063c0988d8a..000000000000
--- a/docs/unify-feedback/feedback-directories.mdx
+++ /dev/null
@@ -1,31 +0,0 @@
----
-title: "Feedback Directories"
-description: "Org-level containers that group related Feedback Records and their sources."
-icon: "folder-tree"
----
-
-A **Feedback Directory** is the top-level container for feedback inside an organization. Every Feedback Record belongs to exactly one directory, and every source writes into a single directory.
-
-## When to create a new directory
-
-Create one directory per logically separate dataset. Common patterns:
-
-- **By product line** (e.g. "Mobile devices", "Web apps")
-- **By stakeholder group** (e.g. "Customers", "Employees")
-- **By region** (e.g. "Europe", "North America")
-
-## Workspace access
-
-Directories live at the **organization** level but are exposed to **workspaces** through an access list. Each workspace can **only access one directory.**
-
-Manage directory access from **Settings → Organization → Feedback Directories**:
-
-- Create new directories
-- Rename or archive directories
-- Add or remove workspace access
-
-Only **Owners** and **Managers** can manage directories. Workspace members see the directories their workspace has access to inside the Unify section.
-
-## Archiving
-
-Archiving a directory hides it from default views but does not delete its records. Use it for one-off programs that have ended.
diff --git a/docs/unify-feedback/feedback-records.mdx b/docs/unify-feedback/feedback-records.mdx
index c55e34807a18..34108160d01a 100644
--- a/docs/unify-feedback/feedback-records.mdx
+++ b/docs/unify-feedback/feedback-records.mdx
@@ -1,6 +1,6 @@
---
title: "Feedback Records"
-description: "The normalized unit of feedback inside a Feedback Directory."
+description: "The normalized unit of feedback inside a Feedback Dataset."
icon: "list-check"
---
@@ -17,7 +17,7 @@ Every record has the following fields. Required fields must be mapped by every s
| `source_type` | string | Yes | The kind of source (e.g. `survey`, `csv`, `review`). |
| `field_id` | string | Yes | Stable identifier for the question/field. |
| `field_type` | enum | Yes | One of `text`, `categorical`, `nps`, `csat`, `ces`, `rating`, `number`, `boolean`, `date`. |
-| `tenant_id` | string | No | Feedback Directory ID. Set automatically when ingesting. |
+| `tenant_id` | string | No | Feedback Dataset ID. Set automatically when ingesting. |
| `source_id` | string | No | Reference to the survey/form/ticket/review ID. |
| `source_name` | string | No | Human-readable source name for display. |
| `field_label` | string | No | The question text or field label. |
@@ -35,11 +35,11 @@ The right `value_*` field is set based on `field_type`. For example a `nps` fiel
## Viewing and managing records
-Inside a workspace, navigate to **Unify → Feedback Records**. You'll see the latest records across every directory the workspace has access to, sorted by `collected_at`.
+Inside a workspace, navigate to **Unify → Feedback Records**. You'll see the latest records across every dataset the workspace has access to, sorted by `collected_at`.
From the table you can:
-- **Filter** by directory, source, field type, or date range.
+- **Filter** by dataset, source, field type, or date range.
- **Open** a record drawer to see the full field set and metadata.
- **Edit** values inline for cleanup (e.g. relabel a categorical answer).
- **Delete** a record.
diff --git a/docs/unify-feedback/feedback-sources.mdx b/docs/unify-feedback/feedback-sources.mdx
index 8f5b9b595763..6c3a9d5c80cb 100644
--- a/docs/unify-feedback/feedback-sources.mdx
+++ b/docs/unify-feedback/feedback-sources.mdx
@@ -1,10 +1,10 @@
---
title: "Feedback Sources"
-description: "Sources that bring feedback data into a Feedback Directory."
+description: "Sources that bring feedback data into a Feedback Dataset."
icon: "plug"
---
-A **Source** defines how external data is mapped into Feedback Records inside a Feedback Directory. Manage them from **Unify → Sources**.
+A **Source** defines how external data is mapped into Feedback Records inside a Feedback Dataset. Manage them from **Unify → Sources**.
## Source types
@@ -12,7 +12,7 @@ Formbricks supports three source types:
### 1. Formbricks Surveys
-Pipe responses from a Formbricks survey directly into a Feedback Directory. Pick a survey, select the questions you want to ingest - that's it. Formbricks automatically maps each question to its `field_type`. Optionally create Feedback Records of existing responses on connect.
+Pipe responses from a Formbricks survey directly into a Feedback Dataset. Pick a survey, select the questions you want to ingest - that's it. Formbricks automatically maps each question to its `field_type`. Optionally create Feedback Records of existing responses on connect.
### 2. CSV Import
@@ -26,7 +26,7 @@ A sample CSV is available from the source creation dialog.
### 3. API Ingestion
-Push records into a directory programmatically from your own systems. Best for server-to-server ingestion. API reference docs are coming soon.
+Push records into a dataset programmatically from your own systems. Best for server-to-server ingestion. API reference docs are coming soon.
## Field mapping
@@ -51,4 +51,4 @@ From the Sources page you can:
- **Create** a new source for any source type.
- **Edit** the mapping for an existing source.
- **Pause** or **resume** ingestion.
-- **Delete** a source. Existing records stay in the directory.
+- **Delete** a source. Existing records stay in the dataset.
diff --git a/docs/unify-feedback/journey-management/close-the-loop.mdx b/docs/unify-feedback/journey-management/close-the-loop.mdx
index 11a966ad04a0..931f4402d7b7 100644
--- a/docs/unify-feedback/journey-management/close-the-loop.mdx
+++ b/docs/unify-feedback/journey-management/close-the-loop.mdx
@@ -21,4 +21,4 @@ A signal changes a journey only when it leads to action. Closed-loop tracking re
* [Webhooks](/platform/features/integrations/webhooks) to push signals into your warehouse, journey tool, or a custom service
* [Native integrations](/platform/features/integrations/overview) to route into Slack, a CRM, or an issue tracker with no code
* [Feedback Records](/unify-feedback/feedback-records) to log the action taken and the recovery outcome
-* [Feedback Directories](/unify-feedback/feedback-directories) per journey so the journey owner has one place to review
+* [Feedback Datasets](/unify-feedback/feedback-datasets) per journey so the journey owner has one place to review
diff --git a/docs/unify-feedback/journey-management/detection-signals.mdx b/docs/unify-feedback/journey-management/detection-signals.mdx
index e3e2638a5873..f16329f15358 100644
--- a/docs/unify-feedback/journey-management/detection-signals.mdx
+++ b/docs/unify-feedback/journey-management/detection-signals.mdx
@@ -21,7 +21,7 @@ Journey Activation runs on two loops. The inner loop opens a case to recover one
* [Actions](/surveys/website-app-surveys/actions) to trigger a survey on the exact behavior that defines the moment
* [Advanced targeting](/surveys/website-app-surveys/advanced-targeting) to fire for the right segment
* [Recontact options](/surveys/website-app-surveys/recontact) to cap contact frequency, set once at the workspace level
-* [Feedback Directories](/unify-feedback/feedback-directories) and [Sources](/unify-feedback/feedback-sources) to land the signal next to the journey's other data
+* [Feedback Datasets](/unify-feedback/feedback-datasets) and [Sources](/unify-feedback/feedback-sources) to land the signal next to the journey's other data
For a custom moment, fire the action from your code, then attach a survey to it:
diff --git a/docs/unify-feedback/journey-management/map-feedback-to-journeys.mdx b/docs/unify-feedback/journey-management/map-feedback-to-journeys.mdx
index a71ff370a86c..fcce668c7c03 100644
--- a/docs/unify-feedback/journey-management/map-feedback-to-journeys.mdx
+++ b/docs/unify-feedback/journey-management/map-feedback-to-journeys.mdx
@@ -18,7 +18,7 @@ Journey Management measures and improves each customer journey end to end, rathe
## Formbricks Approach
-* [Feedback Directories](/unify-feedback/feedback-directories) to hold one journey or program
+* [Feedback Datasets](/unify-feedback/feedback-datasets) to hold one journey or program
* [Feedback Records](/unify-feedback/feedback-records) tagged with journey and stage
* [Hidden Fields](/surveys/general-features/hidden-fields) to pass journey and stage into a link survey at collection time
diff --git a/docs/unify-feedback/journey-management/recurring-patterns.mdx b/docs/unify-feedback/journey-management/recurring-patterns.mdx
index e7161087eec4..533d611b2dc4 100644
--- a/docs/unify-feedback/journey-management/recurring-patterns.mdx
+++ b/docs/unify-feedback/journey-management/recurring-patterns.mdx
@@ -20,6 +20,6 @@ Inner loop actioning resolves issues one customer at a time. Outer loop actionin
* An open follow-up question on every rating to capture the reason behind the score
* [Topics & Subtopics](/unify-feedback/topics-subtopics) to cluster open-text [Feedback Records](/unify-feedback/feedback-records) into themes
-* [Feedback Directories](/unify-feedback/feedback-directories) to keep clustering scoped to one journey
+* [Feedback Datasets](/unify-feedback/feedback-datasets) to keep clustering scoped to one journey
Promote a theme that recurs at the same stage to the pattern register with its stage and rough volume, then hand it to the outer loop for root-cause work.
diff --git a/docs/unify-feedback/overview.mdx b/docs/unify-feedback/overview.mdx
index cfd8c51a9e66..001195b5e4df 100644
--- a/docs/unify-feedback/overview.mdx
+++ b/docs/unify-feedback/overview.mdx
@@ -8,25 +8,25 @@ Unify Feedback is the part of Formbricks that consolidates feedback from across
## Why Unify Feedback
-Most companies collect feedback in many places: surveys, support tickets, app store reviews, NPS tools, sales calls. Each lives in its own silo with its own schema. Unify Feedback normalizes all of these into **Feedback Records** grouped under **Feedback Directories**, so they can be filtered, visualized, and acted on as one dataset.
+Most companies collect feedback in many places: surveys, support tickets, app store reviews, NPS tools, sales calls. Each lives in its own silo with its own schema. Unify Feedback normalizes all of these into **Feedback Records** grouped under **Feedback Datasets**, so they can be filtered, visualized, and acted on together.
## How it works
-1. **Create a Feedback Directory.** A directory is a tenant-scoped bucket for related feedback (for example, "Product Feedback" or "Support 2026").
+1. **Create a Feedback Dataset.** A dataset is a tenant-scoped bucket for related feedback (for example, "Product Feedback" or "Support 2026").
2. **Connect Sources.** Pull data from Formbricks surveys, upload CSVs, or push records via the API.
3. **Explore Records.** Browse, filter, edit, and tag individual Feedback Records.
4. **Discover Topics.** Use vector based Topics & Subtopics (Preview) to cluster open-text feedback.
5. **Visualize.** Build Charts and group them on Dashboards to share insights with your team.
-
+
Org-level buckets that group your feedback.
- The normalized unit of feedback inside a directory.
+ The normalized unit of feedback inside a dataset.
- Connectors that bring data into a directory.
+ Connectors that bring data into a dataset.
Visualize and share feedback insights.
diff --git a/docs/unify-feedback/topics-subtopics.mdx b/docs/unify-feedback/topics-subtopics.mdx
index 61f6c0c6ee05..e59938e1bd54 100644
--- a/docs/unify-feedback/topics-subtopics.mdx
+++ b/docs/unify-feedback/topics-subtopics.mdx
@@ -12,13 +12,13 @@ Open-text feedback ("Why did you give this score?", support tickets, app reviews
## How it works
-1. Pick a Feedback Directory under **Unify → Topics & Subtopics**.
-2. Formbricks scans `value_text` across the directory and proposes a set of **Topics** (broad categories) and **Subtopics** (specific themes within a topic).
+1. Pick a Feedback Dataset under **Unify → Topics & Subtopics**.
+2. Formbricks scans `value_text` across the dataset and proposes a set of **Topics** (broad categories) and **Subtopics** (specific themes within a topic).
3. Each record can be assigned to one Topic and one Subtopic.
## What you can do today
-- **Browse** the proposed Topic / Subtopic tree for a directory.
+- **Browse** the proposed Topic / Subtopic tree for a dataset.
- **Inspect** which records cluster under each Topic.
## Roadmap
@@ -29,5 +29,5 @@ Open-text feedback ("Why did you give this score?", support tickets, app reviews
## Requirements
-- A Feedback Directory with records.
+- A Feedback Dataset with records.
- Smart functionality (AI) enabled at the organization level. See [AI Features](/platform/features/ai-features).
From 7d6efbdf47d83aa0796eebd8b1840db70d597c9b Mon Sep 17 00:00:00 2001
From: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
Date: Mon, 20 Jul 2026 17:12:56 +0530
Subject: [PATCH 5/6] fix(sso): normalize IdP display name & accept common name
punctuation (ENG-1743) (#8574)
Co-authored-by: Claude Opus 4.8 (1M context)
---
.../ee/sso/lib/better-auth-hooks.test.ts | 39 +-
.../modules/ee/sso/lib/better-auth-hooks.ts | 22 +-
apps/web/modules/ee/sso/lib/sso-handlers.ts | 603 ------------
.../modules/ee/sso/lib/sso-provisioning.ts | 13 +-
.../lib/tests/__mock__/sso-handlers.mock.ts | 87 --
.../ee/sso/lib/tests/sso-handlers.test.ts | 894 ------------------
packages/types/user.ts | 24 +-
7 files changed, 82 insertions(+), 1600 deletions(-)
delete mode 100644 apps/web/modules/ee/sso/lib/sso-handlers.ts
delete mode 100644 apps/web/modules/ee/sso/lib/tests/__mock__/sso-handlers.mock.ts
delete mode 100644 apps/web/modules/ee/sso/lib/tests/sso-handlers.test.ts
diff --git a/apps/web/modules/ee/sso/lib/better-auth-hooks.test.ts b/apps/web/modules/ee/sso/lib/better-auth-hooks.test.ts
index b4e8c33fb838..d285edc91885 100644
--- a/apps/web/modules/ee/sso/lib/better-auth-hooks.test.ts
+++ b/apps/web/modules/ee/sso/lib/better-auth-hooks.test.ts
@@ -149,11 +149,46 @@ describe("ssoDatabaseHooks.user.create.before", () => {
expect(gateSsoProvisioning).toHaveBeenCalledWith({ email: "john.doe@example.com", callbackUrl: "/" });
});
- test("keeps the provider-supplied name (no fallback) when present", async () => {
+ test("normalizes a clean provider-supplied name (unchanged, no fallback) when present", async () => {
const result = await runWithSsoRequestContext(() =>
before({ id: "u1", email: "a@b.com", name: "Ada Lovelace" } as never, callbackCtx as never)
);
- expect(result).toEqual({ data: { emailVerified: true, identityProvider: "openid", locale: "en-US" } });
+ expect(result).toEqual({
+ data: { emailVerified: true, identityProvider: "openid", locale: "en-US", name: "Ada Lovelace" },
+ });
+ });
+
+ // ENG-1743: an IdP display name with common punctuation must never fail the sign-in. The provider
+ // name is normalized to a ZUserName-valid form (allowlisted punctuation preserved, the rest collapsed
+ // to a single space), so no `ValidationError: Invalid name format` is thrown from the SSO create path.
+ test.each([
+ { name: "J. Smith", expected: "J. Smith" }, // period preserved
+ { name: "Smith & Co", expected: "Smith & Co" }, // ampersand preserved
+ { name: "Ada O'Neil", expected: "Ada O'Neil" }, // apostrophe kept, double space collapsed
+ { name: "A/B Corp", expected: "A B Corp" }, // slash (not allowlisted) collapsed to space
+ { name: "José 🎉 Núñez", expected: "José Núñez" }, // emoji stripped, accented letters kept
+ ])("normalizes a punctuated provider name '$name' → '$expected'", async ({ name, expected }) => {
+ const result = await runWithSsoRequestContext(() =>
+ before({ id: "u1", email: "a@b.com", name } as never, callbackCtx as never)
+ );
+ expect(result).toMatchObject({ data: { name: expected } });
+ });
+
+ test("falls back to the email local-part when the provider name normalizes to empty", async () => {
+ const result = await runWithSsoRequestContext(() =>
+ before({ id: "u1", email: "jane.doe@example.com", name: "🎉🎉" } as never, callbackCtx as never)
+ );
+ expect(result).toMatchObject({ data: { name: "jane doe" } });
+ });
+
+ // ENG-1743 edge: if BOTH the provider name and the email local-part normalize to empty (a degenerate
+ // service/machine account), fall back to a constant so the stored name is a valid non-empty
+ // ZUserName — otherwise "" would pass Better Auth's create but throw on the user's first profile save.
+ test("falls back to a constant when the provider name and email local-part both normalize to empty", async () => {
+ const result = await runWithSsoRequestContext(() =>
+ before({ id: "u1", email: "🎉@example.com", name: "🎉🎉" } as never, callbackCtx as never)
+ );
+ expect(result).toMatchObject({ data: { name: "User" } });
});
test("leaves email/password sign-ups untouched (gate not run)", async () => {
diff --git a/apps/web/modules/ee/sso/lib/better-auth-hooks.ts b/apps/web/modules/ee/sso/lib/better-auth-hooks.ts
index 732bbe60da2b..630d05196a98 100644
--- a/apps/web/modules/ee/sso/lib/better-auth-hooks.ts
+++ b/apps/web/modules/ee/sso/lib/better-auth-hooks.ts
@@ -4,6 +4,7 @@ import { APIError, createAuthMiddleware, getOAuthState } from "better-auth/api";
import { cookies } from "next/headers";
import { prisma } from "@formbricks/database";
import { SIGNUP_EMAIL_DOMAIN_BLOCKED_ERROR_CODE } from "@formbricks/types/errors";
+import { normalizeUserName } from "@formbricks/types/user";
import { WEBAPP_URL } from "@/lib/constants";
import { identifyPostHogPerson } from "@/lib/posthog";
import { findMatchingLocale } from "@/lib/utils/locale";
@@ -45,12 +46,13 @@ export const getSsoProviderFromContext = (
return match ? match[1] : null;
};
-/** Fallback display name derived from an email local-part (parity `provisionNewSsoUser`:372-377). */
+/**
+ * Fallback display name when the IdP supplies no name: humanize the email local-part (treat `. _ +` as
+ * word separators) and run it through the shared normalizer. The name allowlist lives only in
+ * normalizeUserName (tied to ZUserName), so there is no second name regex here that could drift.
+ */
const deriveNameFromEmail = (email: string): string =>
- email
- .split("@")[0]
- .replace(/[^'\p{L}\p{M}\s\d-]+/gu, " ")
- .trim();
+ normalizeUserName(email.split("@")[0].replace(/[._+]+/g, " "));
/**
* Better Auth `databaseHooks` re-expressing Formbricks' SSO sign-up flow (design doc §13), reusing
@@ -124,7 +126,13 @@ export const ssoDatabaseHooks: NonNullable =
// picture): transformInput drops undefined fields that have no schema default, so this
// prevents a prisma.user.create validation error on SSO sign-up.
image: undefined,
- ...(user.name ? {} : { name: deriveNameFromEmail(user.email) }),
+ // Normalize the IdP-supplied display name to a form ZUserName accepts (ENG-1743): external
+ // provider names are untrusted and may carry punctuation ("J. Smith", "Smith & Co") that
+ // would otherwise persist raw and later break a profile save. Fall back to the email
+ // local-part, then to a constant, so the stored name is always a valid, non-empty
+ // ZUserName — even for degenerate input (emoji-only name + symbol-only email local-part),
+ // which would otherwise re-trigger the ENG-1743 error on the user's first profile save.
+ name: (user.name && normalizeUserName(user.name)) || deriveNameFromEmail(user.email) || "User",
},
};
},
@@ -171,7 +179,7 @@ export const ssoDatabaseHooks: NonNullable =
/**
* Request hook (`hooks.before`) that re-checks the SSO license on every SSO callback — parity with
- * `handleSsoCallback`'s runtime checks (sso-handlers.ts:492-523). Provider registration is gated by
+ * the legacy NextAuth SSO callback's runtime license checks. Provider registration is gated by
* `ENTERPRISE_LICENSE_KEY` (broad); this verifies the specific `sso`/`saml` feature flags on every
* callback. It runs for ALL SSO sign-ins (including existing users, who skip `user.create` and so
* aren't seen by the databaseHooks gate) and catches a license that changes at runtime. Blocks with
diff --git a/apps/web/modules/ee/sso/lib/sso-handlers.ts b/apps/web/modules/ee/sso/lib/sso-handlers.ts
deleted file mode 100644
index 4b4a088b621d..000000000000
--- a/apps/web/modules/ee/sso/lib/sso-handlers.ts
+++ /dev/null
@@ -1,603 +0,0 @@
-import { prisma } from "@formbricks/database";
-import type { IdentityProvider, Organization } from "@formbricks/database/prisma";
-import { logger } from "@formbricks/logger";
-import type { Account } from "@formbricks/types/auth";
-import type { TUser, TUserNotificationSettings } from "@formbricks/types/user";
-import { DEFAULT_TEAM_ID, SKIP_INVITE_FOR_SSO } from "@/lib/constants";
-import { getIsFreshInstance } from "@/lib/instance/service";
-import { verifyInviteToken } from "@/lib/jwt";
-import { createMembership } from "@/lib/membership/service";
-import { capturePostHogEvent } from "@/lib/posthog";
-import { findMatchingLocale } from "@/lib/utils/locale";
-import { redactPII } from "@/lib/utils/logger-helpers";
-import { createBrevoCustomer } from "@/modules/auth/lib/brevo";
-import { createUser, getUserByEmail, updateUser } from "@/modules/auth/lib/user";
-import { getIsValidInviteToken } from "@/modules/auth/signup/lib/invite";
-import { TOidcNameFields, TSamlNameFields } from "@/modules/auth/types/auth";
-import {
- getAccessControlPermission,
- getIsMultiOrgEnabled,
- getIsSamlSsoEnabled,
- getIsSsoEnabled,
-} from "@/modules/ee/license-check/lib/utils";
-import { getFirstOrganization } from "@/modules/ee/sso/lib/organization";
-import { createDefaultTeamMembership, getOrganizationByTeamId } from "@/modules/ee/sso/lib/team";
-import { LINKED_SSO_LOOKUP_SELECT, TSsoLookupUser, syncSsoIdentityForUser } from "./account-linking";
-import { getSsoProviderLookupCandidates, normalizeSsoProvider } from "./provider-normalization";
-import { startSsoRecovery } from "./sso-recovery";
-
-const syncLinkedSsoUser = async ({
- linkedUser,
- user,
- account,
- provider,
- contextLogger,
- logSource,
- legacyAccountIdToNormalize,
-}: {
- linkedUser: Pick;
- user: TUser;
- account: Account;
- provider: IdentityProvider;
- contextLogger: ReturnType;
- logSource: "account_row" | "legacy_account_alias" | "legacy_identity_provider";
- legacyAccountIdToNormalize?: string;
-}) => {
- contextLogger.debug(
- {
- linkedUserId: linkedUser.id,
- emailMatches: linkedUser.email === user.email,
- logSource,
- },
- "Found existing linked SSO user"
- );
-
- if (linkedUser.email === user.email) {
- await syncSsoIdentityForUser({
- userId: linkedUser.id,
- provider,
- account: {
- type: account.type,
- provider,
- providerAccountId: account.providerAccountId,
- access_token: account.access_token,
- refresh_token: account.refresh_token,
- expires_at: account.expires_at,
- scope: account.scope,
- token_type: account.token_type,
- id_token: account.id_token,
- },
- legacyAccountIdToNormalize,
- });
-
- contextLogger.debug(
- { linkedUserId: linkedUser.id, logSource },
- "SSO callback successful: linked user, email matches"
- );
- return true;
- }
-
- contextLogger.debug(
- { linkedUserId: linkedUser.id, logSource },
- "Email changed in SSO provider, checking for conflicts"
- );
-
- const otherUserWithEmail = await getUserByEmail(user.email);
-
- if (!otherUserWithEmail) {
- contextLogger.debug(
- { linkedUserId: linkedUser.id, action: "email_update", logSource },
- "No other user with this email found, updating linked user email after SSO provider change"
- );
-
- await prisma.$transaction(async (tx) => {
- await tx.user.update({
- where: {
- id: linkedUser.id,
- },
- data: {
- email: user.email,
- },
- });
-
- await syncSsoIdentityForUser({
- userId: linkedUser.id,
- provider,
- account: {
- type: account.type,
- provider,
- providerAccountId: account.providerAccountId,
- access_token: account.access_token,
- refresh_token: account.refresh_token,
- expires_at: account.expires_at,
- scope: account.scope,
- token_type: account.token_type,
- id_token: account.id_token,
- },
- tx,
- legacyAccountIdToNormalize,
- });
- });
-
- return true;
- }
-
- contextLogger.debug(
- { linkedUserId: linkedUser.id, conflictingUserId: otherUserWithEmail.id, logSource },
- "SSO callback failed: email conflict after provider change"
- );
-
- throw new Error(
- "Looks like you updated your email somewhere else. A user with this new email exists already."
- );
-};
-
-const findLinkedSsoUser = async ({
- provider,
- providerAccountId,
-}: {
- provider: IdentityProvider;
- providerAccountId: string;
-}): Promise<{
- linkedUser: TSsoLookupUser;
- logSource: "account_row" | "legacy_account_alias";
- legacyAccountIdToNormalize?: string;
-} | null> => {
- const lookupCandidates = getSsoProviderLookupCandidates(provider);
-
- for (const lookupProvider of lookupCandidates) {
- const existingLinkedAccount = await prisma.account.findUnique({
- where: {
- provider_providerAccountId: {
- provider: lookupProvider,
- providerAccountId,
- },
- },
- select: {
- id: true,
- provider: true,
- user: {
- select: LINKED_SSO_LOOKUP_SELECT,
- },
- },
- });
-
- if (!existingLinkedAccount?.user) {
- continue;
- }
-
- if (existingLinkedAccount.provider === provider) {
- return {
- linkedUser: existingLinkedAccount.user,
- logSource: "account_row",
- };
- }
-
- return {
- linkedUser: existingLinkedAccount.user,
- logSource: "legacy_account_alias",
- legacyAccountIdToNormalize: existingLinkedAccount.id,
- };
- }
-
- return null;
-};
-
-const findLegacyExactMatch = async ({
- provider,
- providerAccountId,
-}: {
- provider: IdentityProvider;
- providerAccountId: string;
-}) =>
- prisma.user.findFirst({
- where: {
- identityProvider: provider,
- identityProviderAccountId: providerAccountId,
- },
- select: LINKED_SSO_LOOKUP_SELECT,
- });
-
-const provisionNewSsoUser = async ({
- user,
- account,
- provider,
- callbackUrl,
- contextLogger,
-}: {
- user: TUser;
- account: Account;
- provider: IdentityProvider;
- callbackUrl: string;
- contextLogger: ReturnType;
-}) => {
- let userName = user.name;
-
- if (provider === "openid") {
- const oidcUser = user as TUser & TOidcNameFields;
- if (oidcUser.name) {
- userName = oidcUser.name;
- } else if (oidcUser.given_name || oidcUser.family_name) {
- userName = `${oidcUser.given_name} ${oidcUser.family_name}`;
- } else if (oidcUser.preferred_username) {
- userName = oidcUser.preferred_username;
- }
-
- contextLogger.debug(
- {
- hasName: !!oidcUser.name,
- hasGivenName: !!oidcUser.given_name,
- hasFamilyName: !!oidcUser.family_name,
- hasPreferredUsername: !!oidcUser.preferred_username,
- },
- "Extracted OIDC user name"
- );
- }
-
- if (provider === "saml") {
- const samlUser = user as TUser & TSamlNameFields;
- if (samlUser.name) {
- userName = samlUser.name;
- } else if (samlUser.firstName || samlUser.lastName) {
- userName = `${samlUser.firstName} ${samlUser.lastName}`;
- }
- contextLogger.debug(
- {
- hasName: !!samlUser.name,
- hasFirstName: !!samlUser.firstName,
- hasLastName: !!samlUser.lastName,
- },
- "Extracted SAML user name"
- );
- }
-
- const isMultiOrgEnabled = await getIsMultiOrgEnabled();
- const isFirstUser = await getIsFreshInstance();
-
- contextLogger.debug(
- {
- isMultiOrgEnabled,
- isFirstUser,
- skipInviteForSso: SKIP_INVITE_FOR_SSO,
- hasDefaultTeamId: !!DEFAULT_TEAM_ID,
- },
- "License and instance configuration checked"
- );
-
- if (!isFirstUser && !isMultiOrgEnabled && SKIP_INVITE_FOR_SSO && !DEFAULT_TEAM_ID) {
- contextLogger.error(
- { reason: "missing_default_team_id" },
- "SSO callback rejected: AUTH_SKIP_INVITE_FOR_SSO is enabled but AUTH_SSO_DEFAULT_TEAM_ID is not configured. Refusing to auto-provision new SSO user into an arbitrary organization."
- );
- return false;
- }
-
- if (!isFirstUser && !SKIP_INVITE_FOR_SSO && !isMultiOrgEnabled) {
- if (!callbackUrl) {
- contextLogger.debug(
- { reason: "missing_callback_url" },
- "SSO callback rejected: missing callback URL for invite validation"
- );
- return false;
- }
-
- try {
- const isValidCallbackUrl = new URL(callbackUrl);
- const inviteToken = isValidCallbackUrl.searchParams.get("token") || "";
- const source = isValidCallbackUrl.searchParams.get("source") || "";
-
- if (source === "signin" && !inviteToken) {
- contextLogger.debug(
- { reason: "signin_without_invite_token" },
- "SSO callback rejected: signin without invite token"
- );
- return false;
- }
-
- const { email, inviteId } = verifyInviteToken(inviteToken);
- if (email !== user.email) {
- contextLogger.debug(
- { reason: "invite_email_mismatch", inviteId },
- "SSO callback rejected: invite token email mismatch"
- );
- return false;
- }
-
- const isValidInviteToken = await getIsValidInviteToken(inviteId);
- if (!isValidInviteToken) {
- contextLogger.debug(
- { reason: "invalid_invite_token", inviteId },
- "SSO callback rejected: invalid or expired invite token"
- );
- return false;
- }
- contextLogger.debug({ inviteId }, "Invite token validation successful");
- } catch (err) {
- contextLogger.debug(
- {
- reason: "invite_token_validation_error",
- error: err instanceof Error ? err.message : "unknown_error",
- },
- "SSO callback rejected: invite token validation failed"
- );
- contextLogger.error(err, "Invalid callbackUrl");
- return false;
- }
- }
-
- let organization: Organization | null = null;
-
- if (!isFirstUser && !isMultiOrgEnabled) {
- contextLogger.debug(
- {
- assignmentStrategy: SKIP_INVITE_FOR_SSO && DEFAULT_TEAM_ID ? "default_team" : "first_organization",
- },
- "Determining organization assignment"
- );
- if (SKIP_INVITE_FOR_SSO && DEFAULT_TEAM_ID) {
- organization = await getOrganizationByTeamId(DEFAULT_TEAM_ID);
- } else {
- organization = await getFirstOrganization();
- }
-
- if (!organization) {
- contextLogger.debug(
- { reason: "no_organization_found" },
- "SSO callback rejected: no organization found for assignment"
- );
- return false;
- }
-
- const isAccessControlAllowed = await getAccessControlPermission(organization.id);
- if (!isAccessControlAllowed && !callbackUrl) {
- contextLogger.debug(
- {
- reason: "insufficient_role_permissions",
- organizationId: organization.id,
- isAccessControlAllowed,
- },
- "SSO callback rejected: insufficient role management permissions"
- );
- return false;
- }
- }
-
- contextLogger.debug({ hasUserName: !!userName, identityProvider: provider }, "Creating new SSO user");
- const matchedLocale = await findMatchingLocale();
-
- const userProfile = await prisma.$transaction(async (tx) => {
- const createdUser = await createUser(
- {
- name:
- userName ||
- user.email
- .split("@")[0]
- .replace(/[^'\p{L}\p{M}\s\d-]+/gu, " ")
- .trim(),
- email: user.email,
- emailVerified: true,
- identityProvider: provider,
- identityProviderAccountId: account.providerAccountId,
- locale: matchedLocale,
- },
- tx
- );
-
- await syncSsoIdentityForUser({
- userId: createdUser.id,
- provider,
- account: {
- type: account.type,
- provider,
- providerAccountId: account.providerAccountId,
- access_token: account.access_token,
- refresh_token: account.refresh_token,
- expires_at: account.expires_at,
- scope: account.scope,
- token_type: account.token_type,
- id_token: account.id_token,
- },
- tx,
- });
-
- if (organization) {
- contextLogger.debug(
- { newUserId: createdUser.id, organizationId: organization.id, role: "member" },
- "Assigning user to organization"
- );
- await createMembership(organization.id, createdUser.id, { role: "member", accepted: true }, tx);
-
- if (SKIP_INVITE_FOR_SSO && DEFAULT_TEAM_ID) {
- contextLogger.debug(
- { newUserId: createdUser.id, defaultTeamId: DEFAULT_TEAM_ID },
- "Creating default team membership"
- );
- await createDefaultTeamMembership(createdUser.id, tx);
- }
-
- const updatedNotificationSettings: TUserNotificationSettings = {
- ...createdUser.notificationSettings,
- alert: {
- ...createdUser.notificationSettings?.alert,
- },
- unsubscribedOrganizationIds: Array.from(
- new Set([...(createdUser.notificationSettings?.unsubscribedOrganizationIds || []), organization.id])
- ),
- };
-
- await updateUser(
- createdUser.id,
- {
- notificationSettings: updatedNotificationSettings,
- },
- tx
- );
- }
-
- return createdUser;
- });
-
- contextLogger.debug(
- { newUserId: userProfile.id, identityProvider: provider },
- "New SSO user created successfully"
- );
-
- createBrevoCustomer({ id: userProfile.id, email: userProfile.email });
-
- capturePostHogEvent(userProfile.id, "user_signed_up", {
- auth_provider: provider,
- email_domain: userProfile.email.split("@")[1],
- signup_source: callbackUrl?.includes("token=") ? "invite" : "direct",
- invite_organization_id: organization?.id ?? null,
- });
-
- if (isMultiOrgEnabled) {
- contextLogger.debug(
- { isMultiOrgEnabled, newUserId: userProfile.id },
- "Multi-org enabled, skipping organization assignment"
- );
- return true;
- }
-
- if (organization) {
- return true;
- }
-
- return true;
-};
-
-export const handleSsoCallback = async ({
- user,
- account,
- callbackUrl,
-}: {
- user: TUser;
- account: Account;
- callbackUrl: string;
-}): Promise => {
- const contextLogger = logger.withContext({
- correlationId: crypto.randomUUID(),
- name: "formbricks",
- });
-
- contextLogger.debug(
- {
- ...redactPII({ user, account, callbackUrl }),
- hasEmail: !!user.email,
- hasName: !!user.name,
- },
- "SSO callback initiated"
- );
-
- const isSsoEnabled = await getIsSsoEnabled();
- if (!isSsoEnabled) {
- contextLogger.debug({ isSsoEnabled }, "SSO not enabled");
- return false;
- }
-
- if (!user.email || account.type !== "oauth") {
- contextLogger.debug(
- {
- hasEmail: !!user.email,
- accountType: account.type,
- reason: !user.email ? "missing_email" : "invalid_account_type",
- },
- "SSO callback rejected: missing email or invalid account type"
- );
-
- return false;
- }
-
- const provider = normalizeSsoProvider(account.provider);
- if (!provider) {
- contextLogger.debug({ provider: account.provider }, "SSO callback rejected: unsupported provider");
- return false;
- }
-
- if (provider === "saml") {
- const isSamlSsoEnabled = await getIsSamlSsoEnabled();
- if (!isSamlSsoEnabled) {
- contextLogger.debug({ provider: "saml" }, "SSO callback rejected: SAML not enabled in license");
- return false;
- }
- }
-
- contextLogger.debug(
- { lookupType: "account_provider_account_id" },
- "Checking for existing linked user by provider account"
- );
- const existingLinkedUser = await findLinkedSsoUser({
- provider,
- providerAccountId: account.providerAccountId,
- });
-
- if (existingLinkedUser) {
- return syncLinkedSsoUser({
- linkedUser: existingLinkedUser.linkedUser,
- user,
- account,
- provider,
- contextLogger,
- logSource: existingLinkedUser.logSource,
- legacyAccountIdToNormalize: existingLinkedUser.legacyAccountIdToNormalize,
- });
- }
-
- contextLogger.debug(
- { lookupType: "legacy_identity_provider_account_id" },
- "No account row found, checking for legacy linked SSO user"
- );
- const legacyExactMatch = await findLegacyExactMatch({
- provider,
- providerAccountId: account.providerAccountId,
- });
-
- if (legacyExactMatch) {
- return syncLinkedSsoUser({
- linkedUser: legacyExactMatch,
- user,
- account,
- provider,
- contextLogger,
- logSource: "legacy_identity_provider",
- });
- }
-
- contextLogger.debug({ lookupType: "email" }, "No linked SSO account found, checking for user by email");
- const existingUserWithEmail = await prisma.user.findUnique({
- where: {
- email: user.email,
- },
- select: LINKED_SSO_LOOKUP_SELECT,
- });
-
- if (existingUserWithEmail) {
- contextLogger.debug(
- {
- existingUserId: existingUserWithEmail.id,
- existingIdentityProvider: existingUserWithEmail.identityProvider,
- },
- "SSO callback requires inbox verification before linking"
- );
-
- return startSsoRecovery({
- existingUser: existingUserWithEmail,
- provider,
- account,
- callbackUrl,
- });
- }
-
- contextLogger.debug(
- { action: "new_user_creation" },
- "No existing user found, proceeding with new user creation"
- );
-
- return provisionNewSsoUser({
- user,
- account,
- provider,
- callbackUrl,
- contextLogger,
- });
-};
diff --git a/apps/web/modules/ee/sso/lib/sso-provisioning.ts b/apps/web/modules/ee/sso/lib/sso-provisioning.ts
index 1520e5dade67..d9b0ac4fffe1 100644
--- a/apps/web/modules/ee/sso/lib/sso-provisioning.ts
+++ b/apps/web/modules/ee/sso/lib/sso-provisioning.ts
@@ -29,8 +29,8 @@ export type TSsoProvisioningDecision =
/**
* Validates the invite token carried on an SSO callback URL (only consulted when invites aren't
* skipped). Returns a rejection reason, or null when the invite is valid. Extracted from
- * gateSsoProvisioning so that gate stays under the cognitive-complexity budget — the parity logic
- * (pinned by sso-handlers.test.ts) is unchanged.
+ * gateSsoProvisioning so that gate stays under the cognitive-complexity budget — its behavior is
+ * covered by sso-provisioning.test.ts.
*/
const validateSsoInviteToken = async (email: string, callbackUrl: string): Promise => {
if (!callbackUrl) return "missing_callback_url";
@@ -60,8 +60,8 @@ const validateSsoInviteToken = async (email: string, callbackUrl: string): Promi
};
/**
- * Gate for SSO just-in-time user provisioning — the orphan-safe, WRITE-FREE decision logic mirrored
- * from `provisionNewSsoUser` (sso-handlers.ts:254-363).
+ * Gate for SSO just-in-time user provisioning — the orphan-safe, WRITE-FREE decision logic for the
+ * Better Auth SSO sign-up flow (introduced by the NextAuth→Better Auth migration, ENG-1054).
*
* MUST be called from `databaseHooks.user.create.before`, which Better Auth runs INSIDE the
* user+account transaction: a `"reject"` there → return `false` → the row rolls back, so no orphan
@@ -69,7 +69,7 @@ const validateSsoInviteToken = async (email: string, callbackUrl: string): Promi
* `"provision"` decision (resolved org + flags) is carried to the after-hook, which performs the
* membership writes.
*
- * Parity invariants (pinned by sso-handlers.test.ts): fresh-instance & multi-org bypass all gates;
+ * Invariants (covered by sso-provisioning.test.ts): fresh-instance & multi-org bypass all gates;
* single-org + `SKIP_INVITE_FOR_SSO` requires `DEFAULT_TEAM_ID`; otherwise a valid invite token
* matching the email is required; the assignment org is the default team's org (skip-invite) or the
* first org; access control without a callback URL is refused.
@@ -137,7 +137,8 @@ export const gateSsoProvisioning = async ({
};
/**
- * Provisioning WRITES for a newly created SSO user — mirrors provisionNewSsoUser:404-452. Called from
+ * Provisioning WRITES for a newly created SSO user — mirrors the legacy NextAuth SSO provisioning
+ * writes. Called from
* `databaseHooks.user.create.after` (post-commit), so it CANNOT share Better Auth's user/account
* transaction (design doc §13). It runs its own transaction and is idempotent + best-effort:
* `createMembership`/`createDefaultTeamMembership` upsert, and a failure is retried once then logged
diff --git a/apps/web/modules/ee/sso/lib/tests/__mock__/sso-handlers.mock.ts b/apps/web/modules/ee/sso/lib/tests/__mock__/sso-handlers.mock.ts
deleted file mode 100644
index 510f235be089..000000000000
--- a/apps/web/modules/ee/sso/lib/tests/__mock__/sso-handlers.mock.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-import type { Account } from "@formbricks/types/auth";
-import type { TOrganization } from "@formbricks/types/organizations";
-import type { TUser } from "@formbricks/types/user";
-
-// Mock user data
-export const mockUser: TUser = {
- id: "user-123",
- email: "test@example.com",
- name: "Test User",
- notificationSettings: {
- alert: {},
-
- unsubscribedOrganizationIds: [],
- },
- emailVerified: true,
- twoFactorEnabled: false,
- identityProvider: "google",
- locale: "en-US",
- createdAt: new Date(),
- updatedAt: new Date(),
- lastLoginAt: new Date(),
- isActive: true,
-};
-
-// Mock account data
-export const mockAccount: Account = {
- provider: "google",
- type: "oauth",
- providerAccountId: "provider-123",
-};
-
-// Mock OpenID account
-export const mockOpenIdAccount: Account = {
- ...mockAccount,
- provider: "openid",
-};
-
-// Mock SAML account
-export const mockSamlAccount: Account = {
- ...mockAccount,
- provider: "saml",
-};
-
-// Mock organization data
-export const mockOrganization: TOrganization = {
- id: "org-123",
- name: "Test Organization",
- isAISmartToolsEnabled: false,
- whitelabel: {
- logoUrl: null,
- faviconUrl: null,
- },
- billing: {
- stripeCustomerId: null,
- limits: { monthly: { responses: null }, workspaces: null },
- usageCycleAnchor: new Date(),
- },
- createdAt: new Date(),
- updatedAt: new Date(),
-};
-
-// Mock user with OpenID fields
-export const mockOpenIdUser = (options?: {
- name?: string;
- given_name?: string;
- family_name?: string;
- preferred_username?: string;
- email?: string;
-}): TUser & {
- given_name?: string;
- family_name?: string;
- preferred_username?: string;
-} => ({
- ...mockUser,
- name: options?.name || "",
- given_name: options?.given_name,
- family_name: options?.family_name,
- preferred_username: options?.preferred_username,
- email: options?.email || mockUser.email,
-});
-
-// Mock created user response
-export const mockCreatedUser = (name: string = mockUser.name): TUser => ({
- ...mockUser,
- name,
- emailVerified: true,
-});
diff --git a/apps/web/modules/ee/sso/lib/tests/sso-handlers.test.ts b/apps/web/modules/ee/sso/lib/tests/sso-handlers.test.ts
deleted file mode 100644
index 9dbd116ec51b..000000000000
--- a/apps/web/modules/ee/sso/lib/tests/sso-handlers.test.ts
+++ /dev/null
@@ -1,894 +0,0 @@
-import { beforeEach, describe, expect, test, vi } from "vitest";
-import { prisma } from "@formbricks/database";
-import { Organization } from "@formbricks/database/prisma";
-import { getIsFreshInstance } from "@/lib/instance/service";
-import { verifyInviteToken } from "@/lib/jwt";
-import { createMembership } from "@/lib/membership/service";
-import { capturePostHogEvent } from "@/lib/posthog";
-import { findMatchingLocale } from "@/lib/utils/locale";
-import { createBrevoCustomer } from "@/modules/auth/lib/brevo";
-import { createUser, getUserByEmail, updateUser } from "@/modules/auth/lib/user";
-import { getIsValidInviteToken } from "@/modules/auth/signup/lib/invite";
-import {
- getAccessControlPermission,
- getIsMultiOrgEnabled,
- getIsSamlSsoEnabled,
- getIsSsoEnabled,
-} from "@/modules/ee/license-check/lib/utils";
-import { getFirstOrganization } from "@/modules/ee/sso/lib/organization";
-import { startSsoRecovery } from "@/modules/ee/sso/lib/sso-recovery";
-import { createDefaultTeamMembership, getOrganizationByTeamId } from "@/modules/ee/sso/lib/team";
-import { handleSsoCallback } from "../sso-handlers";
-import {
- mockAccount,
- mockCreatedUser,
- mockOpenIdUser,
- mockOrganization,
- mockSamlAccount,
- mockUser,
-} from "./__mock__/sso-handlers.mock";
-
-vi.mock("@/modules/auth/lib/brevo", () => ({
- createBrevoCustomer: vi.fn(),
-}));
-
-vi.mock("@/modules/auth/lib/user", () => ({
- getUserByEmail: vi.fn(),
- updateUser: vi.fn(),
- createUser: vi.fn(),
-}));
-
-vi.mock("@/modules/auth/signup/lib/invite", () => ({
- getIsValidInviteToken: vi.fn(),
-}));
-
-vi.mock("@/modules/ee/license-check/lib/utils", () => ({
- getIsSamlSsoEnabled: vi.fn(),
- getIsSsoEnabled: vi.fn(),
- getAccessControlPermission: vi.fn(),
- getIsMultiOrgEnabled: vi.fn(),
-}));
-
-vi.mock("@formbricks/database", () => ({
- prisma: {
- $transaction: vi.fn(
- async (callback: (tx: any) => unknown) =>
- await callback({
- account: {
- create: vi.fn(),
- delete: vi.fn(),
- findUnique: vi.fn(),
- update: vi.fn(),
- },
- user: {
- update: vi.fn(),
- },
- })
- ),
- account: {
- create: vi.fn(),
- delete: vi.fn(),
- findUnique: vi.fn(),
- update: vi.fn(),
- },
- user: {
- findFirst: vi.fn(),
- findUnique: vi.fn(),
- update: vi.fn(),
- },
- },
-}));
-
-vi.mock("@/lib/instance/service", () => ({
- getIsFreshInstance: vi.fn(),
-}));
-
-vi.mock("@/modules/ee/sso/lib/organization", () => ({
- getFirstOrganization: vi.fn(),
-}));
-
-vi.mock("@/modules/ee/sso/lib/team", () => ({
- getOrganizationByTeamId: vi.fn(),
- createDefaultTeamMembership: vi.fn(),
-}));
-
-vi.mock("@/lib/membership/service", () => ({
- createMembership: vi.fn(),
-}));
-
-vi.mock("@/lib/utils/locale", () => ({
- findMatchingLocale: vi.fn(),
-}));
-
-vi.mock("@/lib/jwt", () => ({
- verifyInviteToken: vi.fn(),
-}));
-
-vi.mock("@/modules/ee/sso/lib/sso-recovery", () => ({
- startSsoRecovery: vi.fn(),
-}));
-
-vi.mock("@formbricks/logger", () => ({
- logger: {
- error: vi.fn(),
- debug: vi.fn(),
- withContext: vi.fn(() => ({
- debug: vi.fn(),
- error: vi.fn(),
- info: vi.fn(),
- })),
- },
-}));
-
-const constantsOverrides = vi.hoisted(() => ({
- SKIP_INVITE_FOR_SSO: false as boolean,
- DEFAULT_TEAM_ID: "team-123" as string | undefined,
-}));
-
-vi.mock("@/lib/constants", async (importOriginal) => {
- const actual = await importOriginal();
- return {
- ...actual,
- get SKIP_INVITE_FOR_SSO() {
- return constantsOverrides.SKIP_INVITE_FOR_SSO;
- },
- get DEFAULT_TEAM_ID() {
- return constantsOverrides.DEFAULT_TEAM_ID;
- },
- };
-});
-
-vi.mock("@/lib/posthog", () => ({
- capturePostHogEvent: vi.fn(),
-}));
-
-const transactionAccount = {
- create: vi.fn(),
- delete: vi.fn(),
- findUnique: vi.fn(),
- update: vi.fn(),
-};
-
-const transactionUser = {
- update: vi.fn(),
-};
-
-describe("handleSsoCallback", () => {
- beforeEach(() => {
- vi.clearAllMocks();
- constantsOverrides.SKIP_INVITE_FOR_SSO = false;
- constantsOverrides.DEFAULT_TEAM_ID = "team-123";
-
- vi.mocked(prisma.$transaction).mockImplementation(
- async (callback: (tx: any) => unknown) =>
- await callback({
- account: transactionAccount,
- user: transactionUser,
- })
- );
-
- vi.mocked(getIsSsoEnabled).mockResolvedValue(true);
- vi.mocked(getIsSamlSsoEnabled).mockResolvedValue(true);
- vi.mocked(findMatchingLocale).mockResolvedValue("en-US");
- vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(true);
- vi.mocked(getIsFreshInstance).mockResolvedValue(true);
- vi.mocked(getUserByEmail).mockResolvedValue(null);
- vi.mocked(updateUser).mockResolvedValue({ ...mockUser, id: "user-123" });
- vi.mocked(createDefaultTeamMembership).mockResolvedValue(undefined);
- vi.mocked(createMembership).mockResolvedValue({
- role: "member",
- accepted: true,
- userId: mockUser.id,
- organizationId: mockOrganization.id,
- });
- vi.mocked(getFirstOrganization).mockResolvedValue(mockOrganization as unknown as Organization);
- vi.mocked(getOrganizationByTeamId).mockResolvedValue(mockOrganization as unknown as Organization);
- vi.mocked(getAccessControlPermission).mockResolvedValue(true);
- vi.mocked(startSsoRecovery).mockResolvedValue("/auth/verification-requested?token=email-token");
- vi.mocked(getIsValidInviteToken).mockResolvedValue(true);
- vi.mocked(verifyInviteToken).mockReturnValue({
- email: mockUser.email,
- inviteId: "invite-123",
- } as any);
- transactionAccount.findUnique.mockResolvedValue(null);
- transactionAccount.create.mockResolvedValue(undefined);
- transactionAccount.update.mockResolvedValue(undefined);
- transactionAccount.delete.mockResolvedValue(undefined);
- transactionUser.update.mockResolvedValue(undefined);
- });
-
- test("returns false when SSO is disabled", async () => {
- vi.mocked(getIsSsoEnabled).mockResolvedValue(false);
-
- const result = await handleSsoCallback({
- user: mockUser,
- account: mockAccount,
- callbackUrl: "http://localhost:3000",
- });
-
- expect(result).toBe(false);
- });
-
- test("syncs an existing canonical account link when the provider account already exists", async () => {
- vi.mocked(prisma.account.findUnique)
- .mockResolvedValueOnce({
- id: "account_1",
- provider: "google",
- user: {
- ...mockUser,
- email: mockUser.email,
- },
- } as any)
- .mockResolvedValueOnce(null);
- transactionAccount.findUnique.mockResolvedValue({
- id: "account_1",
- userId: mockUser.id,
- } as any);
-
- const result = await handleSsoCallback({
- user: mockUser,
- account: mockAccount,
- callbackUrl: "http://localhost:3000",
- });
-
- expect(result).toBe(true);
- expect(transactionAccount.update).toHaveBeenCalledWith({
- where: {
- id: "account_1",
- },
- data: {},
- });
- expect(transactionUser.update).toHaveBeenCalledWith({
- where: {
- id: mockUser.id,
- },
- data: {
- identityProvider: "google",
- identityProviderAccountId: mockAccount.providerAccountId,
- },
- });
- });
-
- test("normalizes legacy Azure account aliases into the canonical provider id", async () => {
- const azureAccount = { ...mockAccount, provider: "azuread" };
-
- vi.mocked(prisma.account.findUnique)
- .mockResolvedValueOnce(null)
- .mockResolvedValueOnce({
- id: "legacy_account_1",
- provider: "azure-ad",
- user: {
- ...mockUser,
- email: mockUser.email,
- },
- } as any)
- .mockResolvedValueOnce(null);
- transactionAccount.findUnique.mockResolvedValue(null);
-
- const result = await handleSsoCallback({
- user: mockUser,
- account: azureAccount,
- callbackUrl: "http://localhost:3000",
- });
-
- expect(result).toBe(true);
- expect(transactionAccount.update).toHaveBeenCalledWith({
- where: {
- id: "legacy_account_1",
- },
- data: {
- userId: mockUser.id,
- type: "oauth",
- provider: "azuread",
- providerAccountId: mockAccount.providerAccountId,
- },
- });
- });
-
- test("updates the linked SSO user email when the provider email changes without conflicts", async () => {
- vi.mocked(prisma.account.findUnique).mockResolvedValueOnce({
- id: "account_1",
- provider: "google",
- user: {
- ...mockUser,
- id: "linked-user-1",
- email: "old@example.com",
- },
- } as any);
- transactionAccount.findUnique.mockResolvedValue({
- id: "account_1",
- userId: "linked-user-1",
- } as any);
-
- const result = await handleSsoCallback({
- user: {
- ...mockUser,
- email: "new@example.com",
- },
- account: mockAccount,
- callbackUrl: "http://localhost:3000",
- });
-
- expect(result).toBe(true);
- expect(transactionUser.update).toHaveBeenNthCalledWith(1, {
- where: {
- id: "linked-user-1",
- },
- data: {
- email: "new@example.com",
- },
- });
- expect(transactionUser.update).toHaveBeenNthCalledWith(2, {
- where: {
- id: "linked-user-1",
- },
- data: {
- identityProvider: "google",
- identityProviderAccountId: mockAccount.providerAccountId,
- },
- });
- });
-
- test("rejects sign-in when the provider email changes to an email that is already taken", async () => {
- vi.mocked(prisma.account.findUnique).mockResolvedValueOnce({
- id: "account_1",
- provider: "google",
- user: {
- ...mockUser,
- id: "linked-user-1",
- email: "old@example.com",
- },
- } as any);
- vi.mocked(getUserByEmail).mockResolvedValueOnce({
- ...mockUser,
- id: "conflict-user-1",
- email: "new@example.com",
- } as any);
-
- await expect(
- handleSsoCallback({
- user: {
- ...mockUser,
- email: "new@example.com",
- },
- account: mockAccount,
- callbackUrl: "http://localhost:3000",
- })
- ).rejects.toThrow("Looks like you updated your email somewhere else.");
-
- expect(transactionUser.update).not.toHaveBeenCalled();
- });
-
- test("backfills a canonical account row from a legacy exact user match", async () => {
- vi.mocked(prisma.account.findUnique).mockResolvedValue(null);
- vi.mocked(prisma.user.findFirst).mockResolvedValue({
- ...mockUser,
- identityProvider: "google",
- identityProviderAccountId: mockAccount.providerAccountId,
- } as any);
- transactionAccount.findUnique.mockResolvedValue(null);
-
- const result = await handleSsoCallback({
- user: mockUser,
- account: mockAccount,
- callbackUrl: "http://localhost:3000",
- });
-
- expect(result).toBe(true);
- expect(transactionAccount.create).toHaveBeenCalledWith({
- data: {
- userId: mockUser.id,
- type: "oauth",
- provider: "google",
- providerAccountId: mockAccount.providerAccountId,
- },
- });
- expect(transactionUser.update).toHaveBeenCalledWith({
- where: {
- id: mockUser.id,
- },
- data: {
- identityProvider: "google",
- identityProviderAccountId: mockAccount.providerAccountId,
- },
- });
- });
-
- test("starts inbox-based recovery for an existing same-email user without a linked account", async () => {
- vi.mocked(prisma.account.findUnique).mockResolvedValue(null);
- vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
- vi.mocked(prisma.user.findUnique).mockResolvedValue({
- ...mockUser,
- identityProvider: "email",
- identityProviderAccountId: null,
- emailVerified: new Date(),
- } as any);
-
- const result = await handleSsoCallback({
- user: mockUser,
- account: mockAccount,
- callbackUrl: "http://localhost:3000/invite?token=invite-token",
- });
-
- expect(result).toBe("/auth/verification-requested?token=email-token");
- expect(startSsoRecovery).toHaveBeenCalledWith({
- existingUser: expect.objectContaining({
- id: mockUser.id,
- email: mockUser.email,
- }),
- provider: "google",
- account: mockAccount,
- callbackUrl: "http://localhost:3000/invite?token=invite-token",
- });
- expect(createUser).not.toHaveBeenCalled();
- expect(createMembership).not.toHaveBeenCalled();
- expect(createBrevoCustomer).not.toHaveBeenCalled();
- expect(capturePostHogEvent).not.toHaveBeenCalled();
- });
-
- test("keeps unverified email-password users in the recovery flow instead of activating them during SSO", async () => {
- vi.mocked(prisma.account.findUnique).mockResolvedValue(null);
- vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
- vi.mocked(prisma.user.findUnique).mockResolvedValue({
- ...mockUser,
- identityProvider: "email",
- identityProviderAccountId: null,
- emailVerified: null,
- } as any);
-
- const result = await handleSsoCallback({
- user: mockUser,
- account: mockAccount,
- callbackUrl: "http://localhost:3000/invite?token=invite-token",
- });
-
- expect(result).toBe("/auth/verification-requested?token=email-token");
- expect(startSsoRecovery).toHaveBeenCalledWith({
- existingUser: expect.objectContaining({
- id: mockUser.id,
- email: mockUser.email,
- emailVerified: null,
- identityProvider: "email",
- }),
- provider: "google",
- account: mockAccount,
- callbackUrl: "http://localhost:3000/invite?token=invite-token",
- });
- expect(createUser).not.toHaveBeenCalled();
- });
-
- test("starts recovery for a legacy SSO-only user when the stored provider account id is stale", async () => {
- vi.mocked(prisma.account.findUnique).mockResolvedValue(null);
- vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
- vi.mocked(prisma.user.findUnique).mockResolvedValue({
- ...mockUser,
- identityProvider: "google",
- identityProviderAccountId: "legacy-google-subject",
- emailVerified: new Date(),
- password: null,
- } as any);
-
- const result = await handleSsoCallback({
- user: mockUser,
- account: {
- ...mockAccount,
- provider: "google",
- providerAccountId: "new-google-subject",
- },
- callbackUrl: "http://localhost:3000",
- });
-
- expect(result).toBe("/auth/verification-requested?token=email-token");
- expect(startSsoRecovery).toHaveBeenCalledWith({
- existingUser: expect.objectContaining({
- id: mockUser.id,
- email: mockUser.email,
- identityProvider: "google",
- identityProviderAccountId: "legacy-google-subject",
- }),
- provider: "google",
- account: expect.objectContaining({
- provider: "google",
- providerAccountId: "new-google-subject",
- }),
- callbackUrl: "http://localhost:3000",
- });
- expect(createUser).not.toHaveBeenCalled();
- });
-
- test("creates a new SSO user with canonical provider state when no existing user is found", async () => {
- vi.mocked(prisma.account.findUnique).mockResolvedValue(null);
- vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
- vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
- vi.mocked(createUser).mockResolvedValue(mockCreatedUser());
- transactionAccount.findUnique.mockResolvedValue(null);
-
- const result = await handleSsoCallback({
- user: mockUser,
- account: mockAccount,
- callbackUrl: "http://localhost:3000",
- });
-
- expect(result).toBe(true);
- expect(createUser).toHaveBeenCalledWith(
- {
- name: mockUser.name,
- email: mockUser.email,
- emailVerified: true,
- identityProvider: "google",
- identityProviderAccountId: mockAccount.providerAccountId,
- locale: "en-US",
- },
- expect.anything()
- );
- expect(createBrevoCustomer).toHaveBeenCalledWith({ id: mockUser.id, email: mockUser.email });
- expect(capturePostHogEvent).toHaveBeenCalledWith(mockUser.id, "user_signed_up", {
- auth_provider: "google",
- email_domain: "example.com",
- signup_source: "direct",
- invite_organization_id: null,
- });
- });
-
- test("extracts fallback OpenID names when direct name is missing", async () => {
- vi.mocked(prisma.account.findUnique).mockResolvedValue(null);
- vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
- vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
- vi.mocked(createUser).mockResolvedValue(mockCreatedUser("John Doe"));
- transactionAccount.findUnique.mockResolvedValue(null);
-
- const openIdUser = mockOpenIdUser({
- given_name: "John",
- family_name: "Doe",
- });
-
- await handleSsoCallback({
- user: openIdUser,
- account: { ...mockAccount, provider: "openid" },
- callbackUrl: "http://localhost:3000",
- });
-
- expect(createUser).toHaveBeenCalledWith(
- expect.objectContaining({
- name: "John Doe",
- identityProvider: "openid",
- }),
- expect.anything()
- );
- });
-
- test("extracts the preferred OpenID username when no other name fields are present", async () => {
- vi.mocked(prisma.account.findUnique).mockResolvedValue(null);
- vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
- vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
- vi.mocked(createUser).mockResolvedValue(mockCreatedUser("oidc-handle"));
- transactionAccount.findUnique.mockResolvedValue(null);
-
- await handleSsoCallback({
- user: mockOpenIdUser({
- preferred_username: "oidc-handle",
- }),
- account: { ...mockAccount, provider: "openid" },
- callbackUrl: "http://localhost:3000",
- });
-
- expect(createUser).toHaveBeenCalledWith(
- expect.objectContaining({
- name: "oidc-handle",
- identityProvider: "openid",
- }),
- expect.anything()
- );
- });
-
- test("extracts fallback SAML names when the display name is missing", async () => {
- vi.mocked(prisma.account.findUnique).mockResolvedValue(null);
- vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
- vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
- vi.mocked(createUser).mockResolvedValue(mockCreatedUser("Saml User"));
- transactionAccount.findUnique.mockResolvedValue(null);
-
- await handleSsoCallback({
- user: {
- ...mockUser,
- name: "",
- firstName: "Saml",
- lastName: "User",
- } as any,
- account: mockSamlAccount,
- callbackUrl: "http://localhost:3000",
- });
-
- expect(createUser).toHaveBeenCalledWith(
- expect.objectContaining({
- name: "Saml User",
- identityProvider: "saml",
- }),
- expect.anything()
- );
- });
-
- test("rejects new SSO sign-up when invite validation requires a callback URL and none is provided", async () => {
- vi.mocked(prisma.account.findUnique).mockResolvedValue(null);
- vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
- vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
- vi.mocked(getIsFreshInstance).mockResolvedValue(false);
- vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false);
-
- const result = await handleSsoCallback({
- user: mockUser,
- account: mockAccount,
- callbackUrl: "",
- });
-
- expect(result).toBe(false);
- expect(createUser).not.toHaveBeenCalled();
- });
-
- test("rejects sign-in callback URLs that claim a signin source without an invite token", async () => {
- vi.mocked(prisma.account.findUnique).mockResolvedValue(null);
- vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
- vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
- vi.mocked(getIsFreshInstance).mockResolvedValue(false);
- vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false);
-
- const result = await handleSsoCallback({
- user: mockUser,
- account: mockAccount,
- callbackUrl: "http://localhost:3000/auth/login?source=signin",
- });
-
- expect(result).toBe(false);
- expect(createUser).not.toHaveBeenCalled();
- });
-
- test("rejects invite tokens that belong to a different email address", async () => {
- vi.mocked(prisma.account.findUnique).mockResolvedValue(null);
- vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
- vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
- vi.mocked(getIsFreshInstance).mockResolvedValue(false);
- vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false);
- vi.mocked(verifyInviteToken).mockReturnValue({
- email: "someone-else@example.com",
- inviteId: "invite-123",
- } as any);
-
- const result = await handleSsoCallback({
- user: mockUser,
- account: mockAccount,
- callbackUrl: "http://localhost:3000/invite?token=invite-token",
- });
-
- expect(result).toBe(false);
- expect(getIsValidInviteToken).not.toHaveBeenCalled();
- });
-
- test("rejects invalid or expired invite tokens during new SSO sign-up", async () => {
- vi.mocked(prisma.account.findUnique).mockResolvedValue(null);
- vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
- vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
- vi.mocked(getIsFreshInstance).mockResolvedValue(false);
- vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false);
- vi.mocked(getIsValidInviteToken).mockResolvedValue(false);
-
- const result = await handleSsoCallback({
- user: mockUser,
- account: mockAccount,
- callbackUrl: "http://localhost:3000/invite?token=invite-token",
- });
-
- expect(result).toBe(false);
- expect(createUser).not.toHaveBeenCalled();
- });
-
- test("rejects malformed callback URLs during invite validation", async () => {
- vi.mocked(prisma.account.findUnique).mockResolvedValue(null);
- vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
- vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
- vi.mocked(getIsFreshInstance).mockResolvedValue(false);
- vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false);
-
- const result = await handleSsoCallback({
- user: mockUser,
- account: mockAccount,
- callbackUrl: "not-a-valid-url",
- });
-
- expect(result).toBe(false);
- expect(createUser).not.toHaveBeenCalled();
- });
-
- test("rejects new SSO sign-up when no organization can be assigned", async () => {
- vi.mocked(prisma.account.findUnique).mockResolvedValue(null);
- vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
- vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
- vi.mocked(getIsFreshInstance).mockResolvedValue(false);
- vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false);
- vi.mocked(getFirstOrganization).mockResolvedValue(null);
-
- const result = await handleSsoCallback({
- user: mockUser,
- account: mockAccount,
- callbackUrl: "http://localhost:3000/invite?token=invite-token",
- });
-
- expect(result).toBe(false);
- expect(createUser).not.toHaveBeenCalled();
- });
-
- test("rejects auto-provisioning when SKIP_INVITE_FOR_SSO is enabled but DEFAULT_TEAM_ID is missing", async () => {
- vi.mocked(prisma.account.findUnique).mockResolvedValue(null);
- vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
- vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
- vi.mocked(getIsFreshInstance).mockResolvedValue(false);
- vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false);
- constantsOverrides.SKIP_INVITE_FOR_SSO = true;
- constantsOverrides.DEFAULT_TEAM_ID = undefined;
-
- const result = await handleSsoCallback({
- user: mockUser,
- account: mockAccount,
- callbackUrl: "http://localhost:3000",
- });
-
- expect(result).toBe(false);
- expect(getFirstOrganization).not.toHaveBeenCalled();
- expect(getOrganizationByTeamId).not.toHaveBeenCalled();
- expect(createUser).not.toHaveBeenCalled();
- expect(createMembership).not.toHaveBeenCalled();
- });
-
- test("rejects auto-provisioning when DEFAULT_TEAM_ID is empty string", async () => {
- vi.mocked(prisma.account.findUnique).mockResolvedValue(null);
- vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
- vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
- vi.mocked(getIsFreshInstance).mockResolvedValue(false);
- vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false);
- constantsOverrides.SKIP_INVITE_FOR_SSO = true;
- constantsOverrides.DEFAULT_TEAM_ID = "";
-
- const result = await handleSsoCallback({
- user: mockUser,
- account: mockAccount,
- callbackUrl: "http://localhost:3000",
- });
-
- expect(result).toBe(false);
- expect(getFirstOrganization).not.toHaveBeenCalled();
- expect(createUser).not.toHaveBeenCalled();
- });
-
- test("allows auto-provisioning when SKIP_INVITE_FOR_SSO and DEFAULT_TEAM_ID are both set", async () => {
- vi.mocked(prisma.account.findUnique).mockResolvedValue(null);
- vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
- vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
- vi.mocked(getIsFreshInstance).mockResolvedValue(false);
- vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false);
- constantsOverrides.SKIP_INVITE_FOR_SSO = true;
- constantsOverrides.DEFAULT_TEAM_ID = "team-123";
- vi.mocked(createUser).mockResolvedValue(
- mockCreatedUser("Auto Provisioned User") as typeof mockUser & {
- notificationSettings: { alert: Record; unsubscribedOrganizationIds: string[] };
- }
- );
- transactionAccount.findUnique.mockResolvedValue(null);
-
- const result = await handleSsoCallback({
- user: mockUser,
- account: mockAccount,
- callbackUrl: "http://localhost:3000",
- });
-
- expect(result).toBe(true);
- expect(getOrganizationByTeamId).toHaveBeenCalledWith("team-123");
- expect(getFirstOrganization).not.toHaveBeenCalled();
- expect(createMembership).toHaveBeenCalledWith(
- mockOrganization.id,
- mockUser.id,
- { role: "member", accepted: true },
- expect.anything()
- );
- });
-
- test("assigns invited SSO users into the resolved organization and syncs notification settings", async () => {
- vi.mocked(prisma.account.findUnique).mockResolvedValue(null);
- vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
- vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
- vi.mocked(getIsFreshInstance).mockResolvedValue(false);
- vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false);
- vi.mocked(verifyInviteToken).mockReturnValue({
- email: "invited@example.com",
- inviteId: "invite-123",
- } as any);
- vi.mocked(createUser).mockResolvedValue(
- mockCreatedUser("Org User") as typeof mockUser & {
- notificationSettings: { alert: Record; unsubscribedOrganizationIds: string[] };
- }
- );
- transactionAccount.findUnique.mockResolvedValue(null);
-
- const result = await handleSsoCallback({
- user: {
- ...mockUser,
- email: "invited@example.com",
- },
- account: mockAccount,
- callbackUrl: "http://localhost:3000/invite?token=invite-token",
- });
-
- expect(result).toBe(true);
- expect(createMembership).toHaveBeenCalledWith(
- mockOrganization.id,
- mockUser.id,
- { role: "member", accepted: true },
- expect.anything()
- );
- expect(updateUser).toHaveBeenCalledWith(
- mockUser.id,
- {
- notificationSettings: {
- alert: {},
- unsubscribedOrganizationIds: [mockOrganization.id],
- },
- },
- expect.anything()
- );
- expect(capturePostHogEvent).toHaveBeenCalledWith(mockUser.id, "user_signed_up", {
- auth_provider: "google",
- email_domain: "example.com",
- signup_source: "invite",
- invite_organization_id: mockOrganization.id,
- });
- });
-
- test("rejects unsupported providers before any database writes happen", async () => {
- const result = await handleSsoCallback({
- user: mockUser,
- account: {
- ...mockAccount,
- provider: "twitter",
- } as any,
- callbackUrl: "http://localhost:3000",
- });
-
- expect(result).toBe(false);
- expect(prisma.account.findUnique).not.toHaveBeenCalled();
- expect(createUser).not.toHaveBeenCalled();
- });
-
- test("rejects non-oauth accounts and users without an email address", async () => {
- await expect(
- handleSsoCallback({
- user: {
- ...mockUser,
- email: "",
- },
- account: mockAccount,
- callbackUrl: "http://localhost:3000",
- })
- ).resolves.toBe(false);
-
- await expect(
- handleSsoCallback({
- user: mockUser,
- account: {
- ...mockAccount,
- type: "email",
- } as any,
- callbackUrl: "http://localhost:3000",
- })
- ).resolves.toBe(false);
- });
-
- test("rejects SAML sign-in when the license is disabled", async () => {
- vi.mocked(getIsSamlSsoEnabled).mockResolvedValue(false);
-
- const result = await handleSsoCallback({
- user: mockUser,
- account: mockSamlAccount,
- callbackUrl: "http://localhost:3000",
- });
-
- expect(result).toBe(false);
- });
-});
diff --git a/packages/types/user.ts b/packages/types/user.ts
index 8824bc462fa5..a101c7bea8bd 100644
--- a/packages/types/user.ts
+++ b/packages/types/user.ts
@@ -25,13 +25,35 @@ export const ZUserNotificationSettings = z.object({
unsubscribedOrganizationIds: z.array(z.string()).optional(),
});
+// Characters allowed in a user's display name: letters, combining marks, space, and common name
+// punctuation — apostrophe, comma, period, ampersand, parentheses, digits, hyphen. `.` and `&` were
+// added for ENG-1743 so ordinary names ("J. Smith", "Smith & Co") validate instead of being rejected.
+// Declared once so the validator (ZUserName) and the normalizer (normalizeUserName) can never diverge:
+// every string normalizeUserName produces is guaranteed to satisfy ZUserName. `-` stays last so it is
+// a literal, not a range.
+const USER_NAME_ALLOWED_CHARS = "\\p{L}\\p{M} ',.&()\\d-";
+
export const ZUserName = z
.string()
.trim()
.min(1, {
error: "Name should be at least 1 character long",
})
- .regex(/^[\p{L}\p{M} ',()\d-]+$/u, "Invalid name format");
+ .regex(new RegExp(`^[${USER_NAME_ALLOWED_CHARS}]+$`, "u"), "Invalid name format");
+
+/**
+ * Normalize an untrusted display name (e.g. an SSO identity-provider name) into a value that always
+ * satisfies {@link ZUserName}: characters outside the allowlist collapse to a single space, internal
+ * whitespace is collapsed, and the result is trimmed. External IdP names are untrusted input, so we
+ * sanitize them to fit the model rather than rejecting the sign-in (ENG-1743). Returns "" when nothing
+ * survives (e.g. a name of only emoji); callers should fall back to another source such as the email
+ * local-part.
+ */
+export const normalizeUserName = (name: string): string =>
+ name
+ .replace(new RegExp(`[^${USER_NAME_ALLOWED_CHARS}]+`, "gu"), " ")
+ .replace(/\s+/gu, " ")
+ .trim();
export const ZUserEmail = z
.email({
From 8f47cfc5ee9efbfb978e03ba5c9021ce413f777e Mon Sep 17 00:00:00 2001
From: Bhagya Amarasinghe
Date: Mon, 20 Jul 2026 18:33:58 +0530
Subject: [PATCH 6/6] fix: correct Helm release image validation (#8589)
---
.github/workflows/release-helm-chart.yml | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/release-helm-chart.yml b/.github/workflows/release-helm-chart.yml
index 8f8c98d97c6f..7db8ebe9cace 100644
--- a/.github/workflows/release-helm-chart.yml
+++ b/.github/workflows/release-helm-chart.yml
@@ -83,8 +83,9 @@ jobs:
expected_image="ghcr.io/formbricks/formbricks:${VERSION}"
image_count="$(grep -c "image: ${expected_image}$" <<< "$rendered" || true)"
- if [[ "$image_count" -ne 2 ]]; then
- echo "Expected web Deployment and migration Job to render ${expected_image}; found ${image_count} matches"
+ if [[ "$image_count" -ne 3 ]]; then
+ printf 'Expected %s in web Deployment and both migration containers; found %s matches\n' \
+ "$expected_image" "$image_count"
grep "image: ghcr.io/formbricks/formbricks:" <<< "$rendered" || true
exit 1
fi