Compare commits

..

8 Commits

Author SHA1 Message Date
Tiago Farto 0c603f1ad9 fix: reduce airtable oauth callback complexity 2026-05-22 09:13:02 +00:00
Tiago Farto 2c1d963f75 fix: address oauth state review comments 2026-05-22 09:06:06 +00:00
Tiago Farto 26bd02d9ba Merge remote-tracking branch 'origin/main' into fix/integration-oauth-state-csrf
# Conflicts:
#	apps/web/app/api/google-sheet/callback/route.ts
#	apps/web/app/api/v1/integrations/airtable/callback/route.ts
#	apps/web/app/api/v1/integrations/notion/callback/route.ts
#	apps/web/app/api/v1/integrations/slack/callback/route.ts
2026-05-22 09:01:31 +00:00
Tiago Farto 1fb59f4b60 chore: improved test coverage 2026-05-18 12:09:01 +00:00
Tiago Farto ebf8fc017c chore: improve test coverage 2026-05-18 11:57:56 +00:00
Tiago Farto 5c4f5eb0d6 chore: increased test coverage 2026-05-18 11:41:30 +00:00
Tiago Farto fe4b7d9962 chore: linting fixes 2026-05-18 11:20:53 +00:00
Tiago Farto a9939c65c4 fix: add CSRF protection to integration OAuth flows 2026-05-18 10:28:38 +00:00
150 changed files with 1283 additions and 1550 deletions
+1 -1
View File
@@ -47,7 +47,7 @@ jobs:
- name: Set up Helm
uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 # v3.5
with:
version: v3.15.4
version: latest
- name: Log in to GitHub Container Registry
env:
@@ -1,9 +1,11 @@
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
const AccountSettingsLayout = async (props: Readonly<{
params: Promise<{ workspaceId: string }>;
children: React.ReactNode;
}>) => {
const AccountSettingsLayout = async (
props: Readonly<{
params: Promise<{ workspaceId: string }>;
children: React.ReactNode;
}>
) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
return <>{props.children}</>;
@@ -1111,23 +1111,27 @@ export const getResponsesForSummary = reactCache(
skip: offset,
});
const transformedResponses: TSurveySummaryResponse[] = responses.map((responsePrisma) => ({
id: responsePrisma.id,
data: (responsePrisma.data ?? {}) as TResponseData,
updatedAt: responsePrisma.updatedAt,
contact: responsePrisma.contact
? {
id: responsePrisma.contact.id as string,
userId: responsePrisma.contact.attributes.find(
(attribute) => attribute.attributeKey.key === "userId"
)?.value as string,
}
: null,
contactAttributes: (responsePrisma.contactAttributes ?? {}) as TResponseContactAttributes,
language: responsePrisma.language,
ttc: (responsePrisma.ttc ?? {}) as TResponseTtc,
finished: responsePrisma.finished,
}));
const transformedResponses: TSurveySummaryResponse[] = await Promise.all(
responses.map((responsePrisma) => {
return {
id: responsePrisma.id,
data: (responsePrisma.data ?? {}) as TResponseData,
updatedAt: responsePrisma.updatedAt,
contact: responsePrisma.contact
? {
id: responsePrisma.contact.id as string,
userId: responsePrisma.contact.attributes.find(
(attribute) => attribute.attributeKey.key === "userId"
)?.value as string,
}
: null,
contactAttributes: (responsePrisma.contactAttributes ?? {}) as TResponseContactAttributes,
language: responsePrisma.language,
ttc: (responsePrisma.ttc ?? {}) as TResponseTtc,
finished: responsePrisma.finished,
};
})
);
return transformedResponses;
} catch (error) {
@@ -26,8 +26,8 @@ import {
NetworkIcon,
PieChartIcon,
Rows3Icon,
SmilePlusIcon,
SmartphoneIcon,
SmilePlusIcon,
StarIcon,
User,
} from "lucide-react";
+92 -38
View File
@@ -10,52 +10,125 @@ import {
WEBAPP_URL,
} from "@/lib/constants";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import {
IntegrationOAuthStateError,
consumeIntegrationOAuthState,
getSafeOAuthCallbackError,
} from "@/lib/oauth/integration-state";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { hasUserWorkspaceAccess } from "@/lib/workspace/auth";
import { authOptions } from "@/modules/auth/lib/authOptions";
const getGoogleSheetsRedirectUrl = (workspaceId: string) =>
new URL(`/workspaces/${workspaceId}/settings/workspace/integrations/google-sheets`, WEBAPP_URL);
const getGoogleSheetsOAuthState = async (state: string | null, userId: string) => {
try {
return await consumeIntegrationOAuthState({
provider: "googleSheets",
userId,
state,
});
} catch (err) {
if (err instanceof IntegrationOAuthStateError) {
return null;
}
throw err;
}
};
const getGoogleSheetsOAuthClient = () => {
const client_id = GOOGLE_SHEETS_CLIENT_ID;
const client_secret = GOOGLE_SHEETS_CLIENT_SECRET;
const redirect_uri = GOOGLE_SHEETS_REDIRECT_URL;
if (!client_id) {
return { response: responses.internalServerErrorResponse("Google client id is missing") };
}
if (!client_secret) {
return { response: responses.internalServerErrorResponse("Google client secret is missing") };
}
if (!redirect_uri) {
return { response: responses.internalServerErrorResponse("Google redirect url is missing") };
}
return { client: new google.auth.OAuth2(client_id, client_secret, redirect_uri) };
};
const captureGoogleSheetsConnectedEvent = async (userId: string, workspaceId: string) => {
try {
const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId);
capturePostHogEvent(userId, "integration_connected", {
integration_type: "googleSheets",
organization_id: organizationId,
});
capturePostHogEvent(
userId,
"integration_connected",
{
integration_type: "googleSheets",
organization_id: organizationId,
workspace_id: workspaceId,
},
{ organizationId, workspaceId }
);
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for googleSheets");
}
};
export const GET = async (req: Request) => {
const url = new URL(req.url);
const workspaceId = url.searchParams.get("state");
const state = url.searchParams.get("state");
const code = url.searchParams.get("code");
if (!workspaceId) {
return responses.badRequestResponse("Invalid workspaceId");
}
const error = url.searchParams.get("error");
const session = await getServerSession(authOptions);
if (!session) {
return responses.notAuthenticatedResponse();
}
const oauthState = await getGoogleSheetsOAuthState(state, session.user.id);
if (!oauthState) {
return responses.badRequestResponse("Invalid OAuth state");
}
const workspaceId = oauthState.workspaceId;
const canUserAccessWorkspace = await hasUserWorkspaceAccess(session.user.id, workspaceId);
if (!canUserAccessWorkspace) {
return responses.unauthorizedResponse();
}
const basePath = `/workspaces/${workspaceId}/settings/workspace`;
const redirectUrl = getGoogleSheetsRedirectUrl(workspaceId);
const safeError = getSafeOAuthCallbackError(error);
if (safeError) {
redirectUrl.searchParams.set("error", safeError);
return Response.redirect(redirectUrl);
}
if (code && typeof code !== "string") {
return responses.badRequestResponse("`code` must be a string");
}
const client_id = GOOGLE_SHEETS_CLIENT_ID;
const client_secret = GOOGLE_SHEETS_CLIENT_SECRET;
const redirect_uri = GOOGLE_SHEETS_REDIRECT_URL;
if (!client_id) return responses.internalServerErrorResponse("Google client id is missing");
if (!client_secret) return responses.internalServerErrorResponse("Google client secret is missing");
if (!redirect_uri) return responses.internalServerErrorResponse("Google redirect url is missing");
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
const oAuth2ClientResult = getGoogleSheetsOAuthClient();
if ("response" in oAuth2ClientResult) {
return oAuth2ClientResult.response;
}
const oAuth2Client = oAuth2ClientResult.client;
if (!code) {
return Response.redirect(`${WEBAPP_URL}${basePath}/integrations/google-sheets`);
return Response.redirect(redirectUrl);
}
const token = await oAuth2Client.getToken(code);
const key = token.res?.data;
if (!key) {
return Response.redirect(`${WEBAPP_URL}${basePath}/integrations/google-sheets`);
return Response.redirect(redirectUrl);
}
oAuth2Client.setCredentials({ access_token: key.access_token });
@@ -81,29 +154,10 @@ export const GET = async (req: Request) => {
};
const result = await createOrUpdateIntegration(workspaceId, googleSheetIntegration);
if (result) {
try {
const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId);
capturePostHogEvent(session.user.id, "integration_connected", {
integration_type: "googleSheets",
organization_id: organizationId,
});
capturePostHogEvent(
session.user.id,
"integration_connected",
{
integration_type: "googleSheets",
organization_id: organizationId,
workspace_id: workspaceId,
},
{ organizationId, workspaceId }
);
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for googleSheets");
}
return Response.redirect(`${WEBAPP_URL}${basePath}/integrations/google-sheets`);
if (!result) {
return responses.internalServerErrorResponse("Failed to create or update Google Sheets integration");
}
return responses.internalServerErrorResponse("Failed to create or update Google Sheets integration");
await captureGoogleSheetsConnectedEvent(session.user.id, workspaceId);
return Response.redirect(redirectUrl);
};
+17 -1
View File
@@ -1,12 +1,14 @@
import { google } from "googleapis";
import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { responses } from "@/app/lib/api/response";
import {
GOOGLE_SHEETS_CLIENT_ID,
GOOGLE_SHEETS_CLIENT_SECRET,
GOOGLE_SHEETS_REDIRECT_URL,
} from "@/lib/constants";
import { createIntegrationOAuthState } from "@/lib/oauth/integration-state";
import { hasUserWorkspaceAccess } from "@/lib/workspace/auth";
import { authOptions } from "@/modules/auth/lib/authOptions";
@@ -39,12 +41,26 @@ export const GET = async (req: NextRequest) => {
if (!client_secret) return responses.internalServerErrorResponse("Google client secret is missing");
if (!redirect_uri) return responses.internalServerErrorResponse("Google redirect url is missing");
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
let state: string;
try {
state = await createIntegrationOAuthState({
provider: "googleSheets",
userId: session.user.id,
workspaceId,
});
} catch (error) {
logger.error(
{ error, provider: "googleSheets", userId: session.user.id, workspaceId },
"Failed to create Google Sheets OAuth state"
);
return responses.internalServerErrorResponse("Unable to start OAuth flow");
}
const authUrl = oAuth2Client.generateAuthUrl({
access_type: "offline",
scope: scopes,
prompt: "consent",
state: workspaceId,
state,
});
return responses.successResponse({ authUrl });
@@ -5,6 +5,11 @@ import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { fetchAirtableAuthToken } from "@/lib/airtable/service";
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import {
IntegrationOAuthStateError,
consumeIntegrationOAuthState,
getSafeOAuthCallbackError,
} from "@/lib/oauth/integration-state";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { hasUserWorkspaceAccess } from "@/lib/workspace/auth";
@@ -21,6 +26,97 @@ const getEmail = async (token: string) => {
return z.string().parse(res_?.email);
};
const getSanitizedAirtableOAuthError = (error: unknown) => {
if (!(error instanceof Error)) {
return { message: "Unknown Airtable OAuth callback error" };
}
const status = (error as { status?: unknown }).status;
return {
message: error.message,
name: error.name,
...(typeof status === "number" ? { status } : {}),
};
};
const getAirtableOAuthState = async (state: string | null, userId: string) => {
try {
return await consumeIntegrationOAuthState({
provider: "airtable",
userId,
state,
});
} catch (err) {
if (err instanceof IntegrationOAuthStateError) {
return null;
}
throw err;
}
};
const captureAirtableConnectedEvent = async (userId: string, workspaceId: string) => {
try {
const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId);
capturePostHogEvent(
userId,
"integration_connected",
{
integration_type: "airtable",
organization_id: organizationId,
workspace_id: workspaceId,
},
{ organizationId, workspaceId }
);
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for airtable");
}
};
const createAirtableIntegration = async ({
clientId,
code,
codeVerifier,
redirectUri,
workspaceId,
}: {
clientId: string;
code: string;
codeVerifier: string;
redirectUri: string;
workspaceId: string;
}) => {
const key = await fetchAirtableAuthToken({
grant_type: "authorization_code",
code,
redirect_uri: redirectUri,
client_id: clientId,
code_verifier: codeVerifier,
});
if (!key) {
return responses.notFoundResponse("airtable auth token", key);
}
const email = await getEmail(key.access_token);
// Preserve existing integration data (survey-to-table mappings) when re-authorizing
const existingIntegration = await getIntegrationByType(workspaceId, "airtable");
const existingData = existingIntegration?.config?.data ?? [];
await createOrUpdateIntegration(workspaceId, {
type: "airtable" as const,
config: {
key,
data: existingData,
email,
},
});
return null;
};
export const GET = withV1ApiWrapper({
handler: async ({ req, authentication }) => {
if (!authentication || !("user" in authentication)) {
@@ -29,18 +125,22 @@ export const GET = withV1ApiWrapper({
const url = req.url;
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
const workspaceId = queryParams.get("state"); // Get the value of the 'state' parameter
const state = queryParams.get("state");
const code = queryParams.get("code");
const error = queryParams.get("error");
if (!workspaceId) {
const oauthState = await getAirtableOAuthState(state, authentication.user.id);
if (!oauthState) {
return {
response: responses.badRequestResponse("Invalid workspaceId"),
response: responses.badRequestResponse("Invalid OAuth state"),
};
}
if (!code) {
const workspaceId = oauthState.workspaceId;
const codeVerifier = oauthState.pkceCodeVerifier;
if (!workspaceId || !codeVerifier) {
return {
response: responses.badRequestResponse("`code` is missing"),
response: responses.badRequestResponse("Invalid OAuth state"),
};
}
@@ -52,72 +152,55 @@ export const GET = withV1ApiWrapper({
}
const basePath = `/workspaces/${workspaceId}/settings/workspace`;
const redirectUrl = new URL(`${basePath}/integrations/airtable`, WEBAPP_URL);
const safeError = getSafeOAuthCallbackError(error);
if (!code && safeError) {
redirectUrl.searchParams.set("error", safeError);
return {
response: Response.redirect(redirectUrl),
};
}
if (!code) {
return {
response: responses.badRequestResponse("`code` is missing"),
};
}
const client_id = AIRTABLE_CLIENT_ID;
const redirect_uri = WEBAPP_URL + "/api/v1/integrations/airtable/callback";
const code_verifier = Buffer.from(workspaceId + authentication.user.id + workspaceId).toString("base64");
if (!client_id)
return {
response: responses.internalServerErrorResponse("Airtable client id is missing"),
};
const formData = {
grant_type: "authorization_code",
code,
redirect_uri,
client_id,
code_verifier,
};
try {
const key = await fetchAirtableAuthToken(formData);
if (!key) {
return {
response: responses.notFoundResponse("airtable auth token", key),
};
const integrationErrorResponse = await createAirtableIntegration({
clientId: client_id,
code,
codeVerifier,
redirectUri: redirect_uri,
workspaceId,
});
if (integrationErrorResponse) {
return { response: integrationErrorResponse };
}
const email = await getEmail(key.access_token);
// Preserve existing integration data (survey-to-table mappings) when re-authorizing
const existingIntegration = await getIntegrationByType(workspaceId, "airtable");
const existingData = existingIntegration?.config?.data ?? [];
const airtableIntegrationInput = {
type: "airtable" as const,
config: {
key,
data: existingData,
email,
},
};
await createOrUpdateIntegration(workspaceId, airtableIntegrationInput);
try {
const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId);
capturePostHogEvent(
authentication.user.id,
"integration_connected",
{
integration_type: "airtable",
organization_id: organizationId,
workspace_id: workspaceId,
},
{ organizationId, workspaceId }
);
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for airtable");
}
await captureAirtableConnectedEvent(authentication.user.id, workspaceId);
return {
response: Response.redirect(`${WEBAPP_URL}${basePath}/integrations/airtable`),
response: Response.redirect(redirectUrl),
};
} catch (error) {
logger.error({ error, url: req.url }, "Error in GET /api/v1/integrations/airtable/callback");
logger.error(
{ error: getSanitizedAirtableOAuthError(error) },
"Error in GET /api/v1/integrations/airtable/callback"
);
return {
response: responses.internalServerErrorResponse(
error instanceof Error ? error.message : String(error)
),
response: responses.internalServerErrorResponse("Unable to complete Airtable OAuth flow"),
};
}
},
@@ -1,7 +1,7 @@
import crypto from "crypto";
import { responses } from "@/app/lib/api/response";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
import { createIntegrationOAuthState, generatePkcePair } from "@/lib/oauth/integration-state";
import { hasUserWorkspaceAccess } from "@/lib/workspace/auth";
const scope = `data.records:read data.records:write schema.bases:read schema.bases:write user.email:read`;
@@ -33,22 +33,19 @@ export const GET = withV1ApiWrapper({
return {
response: responses.internalServerErrorResponse("Airtable client id is missing"),
};
const codeVerifier = Buffer.from(workspaceId + authentication.user.id + workspaceId).toString("base64");
const codeChallengeMethod = "S256";
const codeChallenge = crypto
.createHash("sha256")
.update(codeVerifier) // hash the code verifier with the sha256 algorithm
.digest("base64") // base64 encode, needs to be transformed to base64url
.replace(/=/g, "") // remove =
.replace(/\+/g, "-") // replace + with -
.replace(/\//g, "_"); // replace / with _ now base64url encoded
const { codeChallenge, codeChallengeMethod, codeVerifier } = generatePkcePair();
const state = await createIntegrationOAuthState({
provider: "airtable",
userId: authentication.user.id,
workspaceId,
pkceCodeVerifier: codeVerifier,
});
const authUrl = new URL("https://airtable.com/oauth2/v1/authorize");
authUrl.searchParams.append("client_id", client_id);
authUrl.searchParams.append("redirect_uri", redirect_uri);
authUrl.searchParams.append("state", workspaceId);
authUrl.searchParams.append("state", state);
authUrl.searchParams.append("scope", scope);
authUrl.searchParams.append("response_type", "code");
authUrl.searchParams.append("code_challenge_method", codeChallengeMethod);
@@ -11,6 +11,11 @@ import {
} from "@/lib/constants";
import { symmetricEncrypt } from "@/lib/crypto";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import {
IntegrationOAuthStateError,
consumeIntegrationOAuthState,
getSafeOAuthCallbackError,
} from "@/lib/oauth/integration-state";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { hasUserWorkspaceAccess } from "@/lib/workspace/auth";
@@ -23,10 +28,28 @@ export const GET = withV1ApiWrapper({
const url = req.url;
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
const workspaceId = queryParams.get("state"); // Get the value of the 'state' parameter
const state = queryParams.get("state");
const code = queryParams.get("code");
const error = queryParams.get("error");
let oauthState;
try {
oauthState = await consumeIntegrationOAuthState({
provider: "notion",
userId: authentication.user.id,
state,
});
} catch (err) {
if (err instanceof IntegrationOAuthStateError) {
return {
response: responses.badRequestResponse("Invalid OAuth state"),
};
}
throw err;
}
const workspaceId = oauthState.workspaceId;
if (!workspaceId) {
return {
response: responses.badRequestResponse("Invalid workspaceId"),
@@ -41,6 +64,8 @@ export const GET = withV1ApiWrapper({
}
const basePath = `/workspaces/${workspaceId}/settings/workspace`;
const redirectUrl = new URL(`${basePath}/integrations/notion`, WEBAPP_URL);
const safeError = getSafeOAuthCallbackError(error);
if (code && typeof code !== "string") {
return {
@@ -48,6 +73,13 @@ export const GET = withV1ApiWrapper({
};
}
if (!code && safeError) {
redirectUrl.searchParams.set("error", safeError);
return {
response: Response.redirect(redirectUrl),
};
}
const client_id = NOTION_OAUTH_CLIENT_ID;
const client_secret = NOTION_OAUTH_CLIENT_SECRET;
const redirect_uri = NOTION_REDIRECT_URI;
@@ -118,13 +150,9 @@ export const GET = withV1ApiWrapper({
}
return {
response: Response.redirect(`${WEBAPP_URL}${basePath}/integrations/notion`),
response: Response.redirect(redirectUrl),
};
}
} else if (error) {
return {
response: Response.redirect(`${WEBAPP_URL}${basePath}/integrations/notion?error=${error}`),
};
}
return {
@@ -6,6 +6,7 @@ import {
NOTION_OAUTH_CLIENT_SECRET,
NOTION_REDIRECT_URI,
} from "@/lib/constants";
import { createIntegrationOAuthState } from "@/lib/oauth/integration-state";
import { hasUserWorkspaceAccess } from "@/lib/workspace/auth";
export const GET = withV1ApiWrapper({
@@ -49,9 +50,16 @@ export const GET = withV1ApiWrapper({
return {
response: responses.internalServerErrorResponse("Notion auth url is missing"),
};
const state = await createIntegrationOAuthState({
provider: "notion",
userId: authentication.user.id,
workspaceId,
});
const authUrlWithState = new URL(auth_url);
authUrlWithState.searchParams.set("state", state);
return {
response: responses.successResponse({ authUrl: `${auth_url}&state=${workspaceId}` }),
response: responses.successResponse({ authUrl: authUrlWithState.toString() }),
};
},
});
@@ -8,6 +8,11 @@ import { responses } from "@/app/lib/api/response";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_REDIRECT_URI, WEBAPP_URL } from "@/lib/constants";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import {
IntegrationOAuthStateError,
consumeIntegrationOAuthState,
getSafeOAuthCallbackError,
} from "@/lib/oauth/integration-state";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { hasUserWorkspaceAccess } from "@/lib/workspace/auth";
@@ -20,10 +25,28 @@ export const GET = withV1ApiWrapper({
const url = req.url;
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
const workspaceId = queryParams.get("state"); // Get the value of the 'state' parameter
const state = queryParams.get("state");
const code = queryParams.get("code");
const error = queryParams.get("error");
let oauthState;
try {
oauthState = await consumeIntegrationOAuthState({
provider: "slack",
userId: authentication.user.id,
state,
});
} catch (err) {
if (err instanceof IntegrationOAuthStateError) {
return {
response: responses.badRequestResponse("Invalid OAuth state"),
};
}
throw err;
}
const workspaceId = oauthState.workspaceId;
if (!workspaceId) {
return {
response: responses.badRequestResponse("Invalid workspaceId"),
@@ -38,6 +61,8 @@ export const GET = withV1ApiWrapper({
}
const basePath = `/workspaces/${workspaceId}/settings/workspace`;
const redirectUrl = new URL(`${basePath}/integrations/slack`, WEBAPP_URL);
const safeError = getSafeOAuthCallbackError(error);
if (code && typeof code !== "string") {
return {
@@ -45,6 +70,13 @@ export const GET = withV1ApiWrapper({
};
}
if (!code && safeError) {
redirectUrl.searchParams.set("error", safeError);
return {
response: Response.redirect(redirectUrl),
};
}
if (!SLACK_CLIENT_ID)
return {
response: responses.internalServerErrorResponse("Slack client id is missing"),
@@ -125,13 +157,9 @@ export const GET = withV1ApiWrapper({
}
return {
response: Response.redirect(`${WEBAPP_URL}${basePath}/integrations/slack`),
response: Response.redirect(redirectUrl),
};
}
} else if (error) {
return {
response: Response.redirect(`${WEBAPP_URL}${basePath}/integrations/slack?error=${error}`),
};
}
return {
@@ -1,6 +1,7 @@
import { responses } from "@/app/lib/api/response";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { SLACK_AUTH_URL, SLACK_CLIENT_ID, SLACK_CLIENT_SECRET } from "@/lib/constants";
import { createIntegrationOAuthState } from "@/lib/oauth/integration-state";
import { hasUserWorkspaceAccess } from "@/lib/workspace/auth";
export const GET = withV1ApiWrapper({
@@ -37,9 +38,16 @@ export const GET = withV1ApiWrapper({
return {
response: responses.internalServerErrorResponse("Slack auth url is missing"),
};
const state = await createIntegrationOAuthState({
provider: "slack",
userId: authentication.user.id,
workspaceId,
});
const authUrl = new URL(SLACK_AUTH_URL);
authUrl.searchParams.set("state", state);
return {
response: responses.successResponse({ authUrl: `${SLACK_AUTH_URL}&state=${workspaceId}` }),
response: responses.successResponse({ authUrl: authUrl.toString() }),
};
},
});
@@ -161,11 +161,15 @@ export const getResponsesByWorkspaceIds = reactCache(
skip: offset ? offset : undefined,
});
const transformedResponses: TResponse[] = responses.map((responsePrisma) => ({
...responsePrisma,
contact: getResponseContact(responsePrisma),
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
}));
const transformedResponses: TResponse[] = await Promise.all(
responses.map((responsePrisma) => {
return {
...responsePrisma,
contact: getResponseContact(responsePrisma),
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
};
})
);
return transformedResponses;
} catch (error) {
@@ -201,11 +205,15 @@ export const getResponses = reactCache(
skip: offset,
});
const transformedResponses: TResponse[] = responses.map((responsePrisma) => ({
...responsePrisma,
contact: getResponseContact(responsePrisma),
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
}));
const transformedResponses: TResponse[] = await Promise.all(
responses.map((responsePrisma) => {
return {
...responsePrisma,
contact: getResponseContact(responsePrisma),
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
};
})
);
return transformedResponses;
} catch (error) {
+1 -1
View File
@@ -237,4 +237,4 @@ export const AUDIT_LOG_GET_USER_IP = env.AUDIT_LOG_GET_USER_IP === "1";
export const SESSION_MAX_AGE = Number(env.SESSION_MAX_AGE) || 86400;
// Control hash for constant-time password verification to prevent timing attacks. Used when user doesn't exist to maintain consistent verification timing
export const CONTROL_HASH = "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q"; //NOSONAR not a real password hash, used only for timing-safe comparison
export const CONTROL_HASH = "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q";
@@ -0,0 +1,268 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { ErrorCode } from "@formbricks/cache";
import { logger } from "@formbricks/logger";
import { cache } from "@/lib/cache";
import {
IntegrationOAuthStateError,
consumeIntegrationOAuthState,
createIntegrationOAuthState,
generatePkcePair,
getSafeOAuthCallbackError,
} from "./integration-state";
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
warn: vi.fn(),
},
}));
vi.mock("@/lib/cache", () => ({
cache: {
getRedisClient: vi.fn(),
set: vi.fn(),
},
}));
const mockCache = vi.mocked(cache);
const oauthStatePayload = {
createdAt: Date.now(),
provider: "slack",
userId: "user-1",
workspaceId: "workspace-1",
} as const;
const mockRedisConsume = (value: unknown) => {
const evalMock = vi.fn().mockResolvedValue(value === null ? null : JSON.stringify(value));
mockCache.getRedisClient.mockResolvedValueOnce({ eval: evalMock } as any);
return evalMock;
};
describe("integration OAuth state", () => {
beforeEach(() => {
vi.resetAllMocks();
mockCache.set.mockResolvedValue({ ok: true, data: undefined });
});
test("creates an opaque cached state that does not expose the workspace id", async () => {
const state = await createIntegrationOAuthState({
provider: "slack",
userId: oauthStatePayload.userId,
workspaceId: oauthStatePayload.workspaceId,
});
expect(state).toMatch(/^[A-Za-z0-9_-]{43,128}$/);
expect(state).not.toContain(oauthStatePayload.workspaceId);
expect(mockCache.set).toHaveBeenCalledWith(
"fb:oauth:state:fake-hash",
expect.objectContaining({
provider: oauthStatePayload.provider,
userId: oauthStatePayload.userId,
workspaceId: oauthStatePayload.workspaceId,
}),
10 * 60 * 1000
);
});
test("stores the PKCE verifier with Airtable OAuth state", async () => {
const pkceCodeVerifier = "E".repeat(43);
await createIntegrationOAuthState({
pkceCodeVerifier,
provider: "airtable",
userId: oauthStatePayload.userId,
workspaceId: oauthStatePayload.workspaceId,
});
expect(mockCache.set).toHaveBeenCalledWith(
"fb:oauth:state:fake-hash",
expect.objectContaining({ pkceCodeVerifier }),
10 * 60 * 1000
);
});
test("consumes a valid state atomically and returns the stored workspace", async () => {
const state = await createIntegrationOAuthState({
provider: "slack",
userId: oauthStatePayload.userId,
workspaceId: oauthStatePayload.workspaceId,
});
const redisEval = mockRedisConsume(oauthStatePayload);
const consumedState = await consumeIntegrationOAuthState({
provider: "slack",
userId: oauthStatePayload.userId,
state,
});
expect(consumedState).toEqual(oauthStatePayload);
expect(redisEval).toHaveBeenCalledWith(expect.stringContaining('redis.call("GET", KEYS[1])'), {
arguments: [],
keys: ["fb:oauth:state:fake-hash"],
});
expect(redisEval).toHaveBeenCalledWith(expect.stringContaining('redis.call("DEL", KEYS[1])'), {
arguments: [],
keys: ["fb:oauth:state:fake-hash"],
});
mockRedisConsume(null);
await expect(
consumeIntegrationOAuthState({
provider: "slack",
userId: oauthStatePayload.userId,
state,
})
).rejects.toThrow(IntegrationOAuthStateError);
});
test("rejects reused or unknown states", async () => {
mockRedisConsume(null);
await expect(
consumeIntegrationOAuthState({
provider: "slack",
userId: oauthStatePayload.userId,
state: "A".repeat(43),
})
).rejects.toThrow(IntegrationOAuthStateError);
});
test("rejects malformed callback state before reading Redis", async () => {
await expect(
consumeIntegrationOAuthState({
provider: "slack",
userId: oauthStatePayload.userId,
state: "too-short",
})
).rejects.toThrow(IntegrationOAuthStateError);
expect(mockCache.getRedisClient).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalled();
});
test("rejects wrong provider and wrong user states", async () => {
mockRedisConsume(oauthStatePayload);
await expect(
consumeIntegrationOAuthState({
provider: "notion",
userId: oauthStatePayload.userId,
state: "B".repeat(43),
})
).rejects.toThrow(IntegrationOAuthStateError);
mockRedisConsume(oauthStatePayload);
await expect(
consumeIntegrationOAuthState({
provider: "slack",
userId: "user-2",
state: "C".repeat(43),
})
).rejects.toThrow(IntegrationOAuthStateError);
});
test("fails closed when cache storage or Redis is unavailable", async () => {
mockCache.set.mockResolvedValueOnce({ ok: false, error: { code: ErrorCode.RedisConnectionError } });
await expect(
createIntegrationOAuthState({
provider: "slack",
userId: oauthStatePayload.userId,
workspaceId: oauthStatePayload.workspaceId,
})
).rejects.toThrow("Unable to start OAuth flow");
mockCache.getRedisClient.mockResolvedValueOnce(null);
await expect(
consumeIntegrationOAuthState({
provider: "slack",
userId: oauthStatePayload.userId,
state: "D".repeat(43),
})
).rejects.toThrow(IntegrationOAuthStateError);
expect(logger.error).toHaveBeenCalled();
});
test("fails closed when Redis client resolution throws", async () => {
mockCache.getRedisClient.mockRejectedValueOnce(new Error("Redis unavailable"));
await expect(
consumeIntegrationOAuthState({
provider: "slack",
userId: oauthStatePayload.userId,
state: "I".repeat(43),
})
).rejects.toThrow(IntegrationOAuthStateError);
expect(logger.error).toHaveBeenCalled();
});
test("rejects malformed cached state values", async () => {
mockRedisConsume({
createdAt: Date.now(),
provider: "slack",
userId: oauthStatePayload.userId,
});
await expect(
consumeIntegrationOAuthState({
provider: "slack",
userId: oauthStatePayload.userId,
state: "F".repeat(43),
})
).rejects.toThrow(IntegrationOAuthStateError);
expect(logger.error).toHaveBeenCalled();
});
test("rejects unexpected cached value types", async () => {
mockCache.getRedisClient.mockResolvedValueOnce({
eval: vi.fn().mockResolvedValue(42),
} as any);
await expect(
consumeIntegrationOAuthState({
provider: "slack",
userId: oauthStatePayload.userId,
state: "G".repeat(43),
})
).rejects.toThrow(IntegrationOAuthStateError);
expect(logger.error).toHaveBeenCalled();
});
test("fails closed when atomic cache consumption fails", async () => {
mockCache.getRedisClient.mockResolvedValueOnce({
eval: vi.fn().mockRejectedValue(new Error("Redis failed")),
} as any);
await expect(
consumeIntegrationOAuthState({
provider: "slack",
userId: oauthStatePayload.userId,
state: "H".repeat(43),
})
).rejects.toThrow(IntegrationOAuthStateError);
expect(logger.error).toHaveBeenCalled();
});
test("generates an RFC 7636 S256 PKCE pair", () => {
const { codeChallenge, codeChallengeMethod, codeVerifier } = generatePkcePair();
expect(codeVerifier).toMatch(/^[A-Za-z0-9_-]{43,128}$/);
expect(codeChallenge).toBe("fake-hash");
expect(codeChallengeMethod).toBe("S256");
});
test("sanitizes provider callback errors", () => {
expect(getSafeOAuthCallbackError("access_denied")).toBe("access_denied");
expect(getSafeOAuthCallbackError("https://evil.example")).toBe("oauth_error");
expect(getSafeOAuthCallbackError(null)).toBeNull();
});
});
+215
View File
@@ -0,0 +1,215 @@
import "server-only";
import crypto from "node:crypto";
import { createCacheKey } from "@formbricks/cache";
import { logger } from "@formbricks/logger";
import { cache } from "@/lib/cache";
const INTEGRATION_OAUTH_STATE_TTL_MS = 10 * 60 * 1000;
const OAUTH_STATE_ENTROPY_BYTES = 32;
const BASE64URL_TOKEN_REGEX = /^[A-Za-z0-9_-]{43,128}$/;
const SAFE_OAUTH_CALLBACK_ERRORS = new Set([
"access_denied",
"invalid_request",
"invalid_scope",
"server_error",
"temporarily_unavailable",
]);
export type TIntegrationOAuthProvider = "googleSheets" | "slack" | "notion" | "airtable";
type TStoredIntegrationOAuthState = {
provider: TIntegrationOAuthProvider;
userId: string;
workspaceId: string;
pkceCodeVerifier?: string;
createdAt: number;
};
type TCreateIntegrationOAuthStateInput = {
provider: TIntegrationOAuthProvider;
userId: string;
workspaceId: string;
pkceCodeVerifier?: string;
};
type TConsumeIntegrationOAuthStateInput = {
provider: TIntegrationOAuthProvider;
userId: string;
state: string | null;
};
export class IntegrationOAuthStateError extends Error {
constructor(message = "Invalid OAuth state") {
super(message);
this.name = "IntegrationOAuthStateError";
}
}
const toBase64Url = (buffer: Buffer) =>
buffer.toString("base64").replaceAll("=", "").replaceAll("+", "-").replaceAll("/", "_");
const generateRandomToken = () => toBase64Url(crypto.randomBytes(OAUTH_STATE_ENTROPY_BYTES));
const hashState = (state: string) => crypto.createHash("sha256").update(state).digest("hex");
const getIntegrationOAuthStateCacheKey = (stateHash: string) =>
createCacheKey.custom("oauth", "state", stateHash);
const getValidToken = (token: string | undefined, label: string) => {
if (!token || !BASE64URL_TOKEN_REGEX.test(token)) {
throw new IntegrationOAuthStateError(`Invalid OAuth ${label}`);
}
return token;
};
const parseStoredIntegrationOAuthState = (serializedValue: string): TStoredIntegrationOAuthState => {
try {
const parsedValue = JSON.parse(serializedValue) as Partial<TStoredIntegrationOAuthState>;
if (
!parsedValue ||
typeof parsedValue.provider !== "string" ||
typeof parsedValue.userId !== "string" ||
typeof parsedValue.workspaceId !== "string" ||
typeof parsedValue.createdAt !== "number" ||
(parsedValue.pkceCodeVerifier !== undefined && typeof parsedValue.pkceCodeVerifier !== "string")
) {
throw new Error("Invalid stored OAuth state shape");
}
return parsedValue as TStoredIntegrationOAuthState;
} catch (error) {
logger.error({ error }, "Failed to parse stored integration OAuth state");
throw new IntegrationOAuthStateError();
}
};
const consumeCachedIntegrationOAuthState = async (
cacheKey: string,
logContext: Record<string, unknown>
): Promise<TStoredIntegrationOAuthState | null> => {
let redis;
try {
redis = await cache.getRedisClient();
} catch (error) {
logger.error({ ...logContext, error }, "Failed to resolve Redis client for integration OAuth state");
throw new IntegrationOAuthStateError("Unable to validate OAuth state");
}
if (!redis) {
logger.error({ ...logContext }, "Redis is required to validate integration OAuth state");
throw new IntegrationOAuthStateError("Unable to validate OAuth state");
}
try {
const serializedValue = await redis.eval(
`
local value = redis.call("GET", KEYS[1])
if value then
redis.call("DEL", KEYS[1])
end
return value
`,
{
arguments: [],
keys: [cacheKey],
}
);
if (serializedValue === null) {
return null;
}
if (typeof serializedValue !== "string") {
logger.error({ ...logContext }, "Unexpected cached integration OAuth state value");
throw new IntegrationOAuthStateError();
}
return parseStoredIntegrationOAuthState(serializedValue);
} catch (error) {
if (error instanceof IntegrationOAuthStateError) {
throw error;
}
logger.error({ ...logContext, error }, "Failed to consume integration OAuth state");
throw new IntegrationOAuthStateError("Unable to validate OAuth state");
}
};
export const createIntegrationOAuthState = async ({
provider,
userId,
workspaceId,
pkceCodeVerifier,
}: TCreateIntegrationOAuthStateInput): Promise<string> => {
if (pkceCodeVerifier !== undefined) {
getValidToken(pkceCodeVerifier, "PKCE verifier");
}
const state = generateRandomToken();
const stateHash = hashState(state);
const cacheKey = getIntegrationOAuthStateCacheKey(stateHash);
const storedState: TStoredIntegrationOAuthState = {
provider,
userId,
workspaceId,
pkceCodeVerifier,
createdAt: Date.now(),
};
const result = await cache.set(cacheKey, storedState, INTEGRATION_OAUTH_STATE_TTL_MS);
if (!result.ok) {
logger.error({ error: result.error, provider, userId, workspaceId }, "Failed to store OAuth state");
throw new Error("Unable to start OAuth flow");
}
return state;
};
export const consumeIntegrationOAuthState = async ({
provider,
userId,
state,
}: TConsumeIntegrationOAuthStateInput): Promise<TStoredIntegrationOAuthState> => {
let providedState;
try {
providedState = getValidToken(state ?? undefined, "state");
} catch (error) {
logger.warn({ provider, userId }, "Integration OAuth callback rejected due to malformed state");
throw error;
}
const stateHash = hashState(providedState);
const cacheKey = getIntegrationOAuthStateCacheKey(stateHash);
const storedState = await consumeCachedIntegrationOAuthState(cacheKey, { provider, stateHash, userId });
if (storedState?.provider !== provider || storedState?.userId !== userId) {
logger.warn({ provider, stateHash, userId }, "Integration OAuth callback rejected due to invalid state");
throw new IntegrationOAuthStateError();
}
return storedState;
};
export const getSafeOAuthCallbackError = (error: string | null): string | null => {
if (!error) {
return null;
}
return SAFE_OAUTH_CALLBACK_ERRORS.has(error) ? error : "oauth_error";
};
export const generatePkcePair = () => {
const verifier = generateRandomToken();
const challenge = toBase64Url(crypto.createHash("sha256").update(verifier).digest());
return {
codeChallenge: challenge,
codeChallengeMethod: "S256" as const,
codeVerifier: verifier,
};
};
-26
View File
@@ -46,13 +46,6 @@ vi.mock("@/modules/ee/billing/lib/organization-billing", () => ({
cleanupStripeCustomer: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/modules/hub/service", () => ({
deleteHubTenantData: vi.fn().mockResolvedValue({
data: { deletedFeedbackRecords: 0, deletedEmbeddings: 0, deletedWebhooks: 0 },
error: null,
}),
}));
describe("Organization Service", () => {
beforeEach(() => {
vi.mocked(ensureCloudStripeSetupForOrganization).mockResolvedValue(undefined);
@@ -357,7 +350,6 @@ describe("Organization Service", () => {
billing: { stripeCustomerId: "cus_123" },
memberships: [],
workspaces: [],
feedbackDirectories: [],
} as any);
await deleteOrganization("org1");
@@ -366,23 +358,5 @@ describe("Organization Service", () => {
expect(cleanupStripeCustomer).toHaveBeenCalledWith("cus_123");
}
});
test("should purge Hub-owned data for each feedback directory", async () => {
const { deleteHubTenantData } = await import("@/modules/hub/service");
vi.mocked(prisma.organization.delete).mockResolvedValue({
id: "org1",
name: "Test Org",
billing: null,
memberships: [],
workspaces: [],
feedbackDirectories: [{ id: "frd_1" }, { id: "frd_2" }],
} as any);
await deleteOrganization("org1");
expect(deleteHubTenantData).toHaveBeenCalledTimes(2);
expect(deleteHubTenantData).toHaveBeenCalledWith("frd_1");
expect(deleteHubTenantData).toHaveBeenCalledWith("frd_2");
});
});
});
-13
View File
@@ -19,7 +19,6 @@ import { updateUser } from "@/lib/user/service";
import { getBillingUsageCycleWindow } from "@/lib/utils/billing";
import { getWorkspaces } from "@/lib/workspace/service";
import { cleanupStripeCustomer } from "@/modules/ee/billing/lib/organization-billing";
import { deleteHubTenantData } from "@/modules/hub/service";
import { validateInputs } from "../utils/validate";
export const select = {
@@ -293,11 +292,6 @@ export const deleteOrganization = async (organizationId: string) => {
id: true,
},
},
feedbackDirectories: {
select: {
id: true,
},
},
},
});
@@ -305,13 +299,6 @@ export const deleteOrganization = async (organizationId: string) => {
if (IS_FORMBRICKS_CLOUD && stripeCustomerId) {
await cleanupStripeCustomer(stripeCustomerId);
}
// Best-effort: purge Hub-owned data (feedback records, embeddings, webhooks) for each
// directory tenant. Failures are logged inside the gateway and do not roll back the
// local delete.
for (const directory of deletedOrganization.feedbackDirectories) {
await deleteHubTenantData(directory.id);
}
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
+11 -9
View File
@@ -338,15 +338,17 @@ export const getResponses = reactCache(
skip: offset,
});
const transformedResponses: TResponseWithQuotas[] = responses.map((responsePrisma) => {
const { quotaLinks, ...response } = responsePrisma;
return {
...response,
contact: getResponseContact(responsePrisma),
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
quotas: quotaLinks.map((quotaLinkPrisma) => quotaLinkPrisma.quota),
};
});
const transformedResponses: TResponseWithQuotas[] = await Promise.all(
responses.map((responsePrisma) => {
const { quotaLinks, ...response } = responsePrisma;
return {
...response,
contact: getResponseContact(responsePrisma),
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
quotas: quotaLinks.map((quotaLinkPrisma) => quotaLinkPrisma.quota),
};
})
);
return transformedResponses;
} catch (error) {
+2 -8
View File
@@ -74,10 +74,7 @@ describe("convertToCsv", () => {
test("should preserve distinct columns whose labels collide after sanitization", async () => {
// "=field" and "'=field" both render as "'=field" once defanged, but the
// underlying row keys must stay distinct so neither cell is dropped.
const csv = await convertToCsv(
["=field", "'=field"],
[{ "=field": "a", "'=field": "b" }]
);
const csv = await convertToCsv(["=field", "'=field"], [{ "=field": "a", "'=field": "b" }]);
const lines = csv.trim().split("\n");
expect(lines[0]).toBe('"\'=field","\'=field"');
expect(lines[1]).toBe('"a","b"');
@@ -143,10 +140,7 @@ describe("convertToXlsxBuffer", () => {
test("should preserve distinct xlsx columns whose labels collide after sanitization", () => {
// Original keys "=field" and "'=field" both render as "'=field"; ensure
// both cells survive instead of one overwriting the other.
const buffer = convertToXlsxBuffer(
["=field", "'=field"],
[{ "=field": "a", "'=field": "b" }]
);
const buffer = convertToXlsxBuffer(["=field", "'=field"], [{ "=field": "a", "'=field": "b" }]);
const wb = xlsx.read(buffer, { type: "buffer" });
const sheet = wb.Sheets["Sheet1"];
expect(sheet["A1"].v).toBe("'=field");
+1 -1
View File
@@ -9,7 +9,7 @@ describe("promises utilities", () => {
const promise = delay(delayTime);
vi.advanceTimersByTime(delayTime);
await expect(promise).resolves.toBeUndefined();
await promise;
vi.useRealTimers();
});
@@ -73,4 +73,10 @@ describe("getContacts", () => {
where: { workspaceId: { in: mockWorkspaceIds } },
});
});
test("should get contacts", async () => {
vi.mocked(prisma.contact.findMany).mockResolvedValue(mockContacts);
await getContacts(mockWorkspaceIds);
});
});
@@ -1,4 +1,4 @@
import { describe, expect, test, vi } from "vitest";
import { describe, expect, test } from "vitest";
import { ZodError } from "zod";
import {
ZContact,
@@ -648,10 +648,10 @@ describe("validateUniqueAttributeKeys", () => {
},
];
const mockCtx = {
addIssue: vi.fn(),
addIssue: () => {},
} as any;
// Should not throw or call addIssue
validateUniqueAttributeKeys(attributes, mockCtx);
expect(mockCtx.addIssue).not.toHaveBeenCalled();
});
test("should fail validation for duplicate attribute keys", () => {
+3 -4
View File
@@ -292,10 +292,9 @@ describe("Quota Utils", () => {
test("should handle empty quota arrays within transaction", async () => {
await upsertResponseQuotaLinks(mockResponseId, [], [], [], asTx(mockTx));
// deleteMany always runs; create/update are skipped when arrays are empty
expect(mockTx.responseQuotaLink.deleteMany).toHaveBeenCalledTimes(1);
expect(mockTx.responseQuotaLink.createMany).not.toHaveBeenCalled();
expect(mockTx.responseQuotaLink.updateMany).not.toHaveBeenCalled();
// Verify transaction was called even with empty arrays
// expect(mockTx).toHaveBeenCalledTimes(1);
// expect(mockTx).toHaveBeenCalledWith(expect.any(Function));
});
test("should execute correct operations within transaction", async () => {
@@ -125,16 +125,14 @@ describe("Team Management", () => {
describe("error handling", () => {
test("handles missing default team gracefully", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValue(null);
await expect(createDefaultTeamMembership(MOCK_IDS.userId)).resolves.toBeUndefined();
expect(prisma.teamUser.upsert).not.toHaveBeenCalled();
await createDefaultTeamMembership(MOCK_IDS.userId);
});
test("handles missing organization membership gracefully", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_DEFAULT_TEAM);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(null);
await expect(createDefaultTeamMembership(MOCK_IDS.userId)).resolves.toBeUndefined();
expect(prisma.teamUser.upsert).not.toHaveBeenCalled();
await createDefaultTeamMembership(MOCK_IDS.userId);
});
test("handles database errors gracefully", async () => {
@@ -142,7 +140,7 @@ describe("Team Management", () => {
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(MOCK_ORGANIZATION_MEMBERSHIP);
vi.mocked(prisma.teamUser.upsert).mockRejectedValue(new Error("Database error"));
await expect(createDefaultTeamMembership(MOCK_IDS.userId)).resolves.toBeUndefined();
await createDefaultTeamMembership(MOCK_IDS.userId);
});
});
});
-43
View File
@@ -5,7 +5,6 @@ import {
createFeedbackRecord,
createFeedbackRecordsBatch,
deleteFeedbackRecord,
deleteHubTenantData,
getFeedbackRecordTenant,
listFeedbackRecords,
retrieveFeedbackRecord,
@@ -345,48 +344,6 @@ describe("hub service", () => {
});
});
describe("deleteHubTenantData", () => {
test("returns config error when getHubClient returns null", async () => {
vi.mocked(getHubClient).mockReturnValue(null);
const result = await deleteHubTenantData("tenant-1");
expect(result.data).toBeNull();
expect(result.error?.message).toContain("HUB_API_KEY");
});
test("returns mapped data when client.delete resolves", async () => {
const deleteSpy = vi.fn().mockResolvedValue({
tenant_id: "tenant-1",
deleted_feedback_records: 3,
deleted_embeddings: 5,
deleted_webhooks: 1,
});
vi.mocked(getHubClient).mockReturnValue({ delete: deleteSpy } as any);
const result = await deleteHubTenantData("tenant-1");
expect(deleteSpy).toHaveBeenCalledWith("/v1/tenants/tenant-1/data");
expect(result.error).toBeNull();
expect(result.data).toEqual({
deletedFeedbackRecords: 3,
deletedEmbeddings: 5,
deletedWebhooks: 1,
});
});
test("returns error when client.delete throws", async () => {
vi.mocked(getHubClient).mockReturnValue({
delete: vi.fn().mockRejectedValue(new Error("network")),
} as any);
const result = await deleteHubTenantData("tenant-1");
expect(result.data).toBeNull();
expect(result.error).toMatchObject({ status: 0, message: "network" });
});
});
describe("createFeedbackRecordsBatch", () => {
test("returns all errors when getHubClient returns null", async () => {
vi.mocked(getHubClient).mockReturnValue(null);
-51
View File
@@ -129,57 +129,6 @@ export const deleteFeedbackRecord = async (id: string): Promise<HubFeedbackRecor
}
};
export type HubTenantDataDeleteResult = {
data: {
deletedFeedbackRecords: number;
deletedEmbeddings: number;
deletedWebhooks: number;
} | null;
error: HubError | null;
};
type TenantDataDeleteResponse = {
tenant_id: string;
deleted_feedback_records: number;
deleted_embeddings: number;
deleted_webhooks: number;
message?: string;
};
/**
* Purge all Hub-owned data (feedback records, derived embeddings, webhooks) for a tenant.
* Called when the owning organization is deleted so Hub-side rows don't become orphaned.
* Idempotent on the Hub side; the caller treats failures as best-effort.
*
* Hits `DELETE /v1/tenants/{tenant_id}/data` directly because the SDK doesn't yet expose
* a typed method for this endpoint.
*/
export const deleteHubTenantData = async (tenantId: string): Promise<HubTenantDataDeleteResult> => {
const client = getHubClient();
if (!client) {
return { data: null, error: { ...NO_CONFIG_ERROR } };
}
try {
const data = await client.delete<TenantDataDeleteResponse>(
`/v1/tenants/${encodeURIComponent(tenantId)}/data`
);
return {
data: {
deletedFeedbackRecords: data.deleted_feedback_records,
deletedEmbeddings: data.deleted_embeddings,
deletedWebhooks: data.deleted_webhooks,
},
error: null,
};
} catch (err) {
logger.warn({ err, tenantId }, "Hub: deleteHubTenantData failed");
const status = getErrorStatus(err);
const message = getErrorMessage(err);
return { data: null, error: { status, message, detail: message } };
}
};
export type ListFeedbackRecordsResult = {
data: FeedbackRecordListResponse | null;
error: HubError | null;
@@ -103,7 +103,6 @@ describe("getActionClasses", () => {
// We need to import the actual react cache to test it with vi.spyOn if we weren't mocking it.
// However, since we are mocking it to be a pass-through, we just check if our main cache is called.
const result = await getActionClasses(workspaceId);
expect(result).toEqual(mockActionClasses);
await getActionClasses(workspaceId);
});
});
@@ -87,7 +87,6 @@ export const SurveyBgSelectorTab = ({
if (isUnsplashConfigured) {
return <ImageFromUnsplashSurveyBg handleBgChange={handleBgChange} />;
}
return null;
default:
return null;
}
-1
View File
@@ -60,6 +60,5 @@ test.describe("CX Onboarding", async () => {
await page.getByRole("button", { name: "Save & Close" }).click();
await page.waitForURL(/\/workspaces\/[^/]+\/surveys\/[^/]+\/summary(\?.*)?$/);
await expect(page).toHaveURL(/\/workspaces\/[^/]+\/surveys\/[^/]+\/summary(\?.*)?$/);
});
});
+1 -1
View File
@@ -247,6 +247,6 @@ vi.mock("@/lib/constants", async (importOriginal) => {
RATE_LIMITING_DISABLED: false,
TELEMETRY_DISABLED: false,
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: 30,
CONTROL_HASH: "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q", //NOSONAR mirrors production CONTROL_HASH, not a real password hash
CONTROL_HASH: "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q",
};
});
+19 -32
View File
@@ -244,9 +244,8 @@ Embedding API key value for the generated embeddings secret.
{{- $secretName := include "formbricks.hubEmbeddingsSecretName" . }}
{{- $secretKey := .Values.hub.embeddings.auth.secretKey | default "EMBEDDING_PROVIDER_API_KEY" }}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace $secretName) }}
{{- $secretData := dig "data" dict $secret }}
{{- if index $secretData $secretKey }}
{{- index $secretData $secretKey | b64dec -}}
{{- if and $secret (index $secret.data $secretKey) }}
{{- index $secret.data $secretKey | b64dec -}}
{{- else if .Values.hub.embeddings.auth.apiKey }}
{{- .Values.hub.embeddings.auth.apiKey -}}
{{- else }}
@@ -292,9 +291,8 @@ true
{{- define "formbricks.postgresAdminPassword" -}}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "formbricks.appSecretName" .)) }}
{{- $secretData := dig "data" dict $secret }}
{{- if index $secretData "POSTGRES_ADMIN_PASSWORD" }}
{{- index $secretData "POSTGRES_ADMIN_PASSWORD" | b64dec -}}
{{- if and $secret (index $secret.data "POSTGRES_ADMIN_PASSWORD") }}
{{- index $secret.data "POSTGRES_ADMIN_PASSWORD" | b64dec -}}
{{- else }}
{{- randAlphaNum 16 -}}
{{- end -}}
@@ -302,9 +300,8 @@ true
{{- define "formbricks.postgresUserPassword" -}}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "formbricks.appSecretName" .)) }}
{{- $secretData := dig "data" dict $secret }}
{{- if index $secretData "POSTGRES_USER_PASSWORD" }}
{{- index $secretData "POSTGRES_USER_PASSWORD" | b64dec -}}
{{- if and $secret (index $secret.data "POSTGRES_USER_PASSWORD") }}
{{- index $secret.data "POSTGRES_USER_PASSWORD" | b64dec -}}
{{- else }}
{{- randAlphaNum 16 -}}
{{- end -}}
@@ -314,9 +311,8 @@ true
{{- $redisSecretName := include "formbricks.redisSecretName" . }}
{{- $redisSecretKey := include "formbricks.redisSecretKey" . }}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace $redisSecretName) }}
{{- $secretData := dig "data" dict $secret }}
{{- if index $secretData $redisSecretKey }}
{{- index $secretData $redisSecretKey | b64dec -}}
{{- if and $secret (index $secret.data $redisSecretKey) }}
{{- index $secret.data $redisSecretKey | b64dec -}}
{{- else if eq $redisSecretName (include "formbricks.appSecretName" .) }}
{{- randAlphaNum 16 -}}
{{- else }}
@@ -326,10 +322,9 @@ true
{{- define "formbricks.cronSecret" -}}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "formbricks.appSecretName" .)) }}
{{- $secretData := dig "data" dict $secret }}
{{- if index $secretData "CRON_SECRET" }}
{{- index $secretData "CRON_SECRET" | b64dec -}}
{{- else if and $secret (hasKey $secret "data") }}
{{- if and $secret (index $secret.data "CRON_SECRET") }}
{{- index $secret.data "CRON_SECRET" | b64dec -}}
{{- else if $secret }}
{{- fail (printf "Secret %q exists in namespace %q but is missing CRON_SECRET" (include "formbricks.appSecretName" .) .Release.Namespace) -}}
{{- else }}
{{- randAlphaNum 32 -}}
@@ -338,11 +333,8 @@ true
{{- define "formbricks.encryptionKey" -}}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "formbricks.appSecretName" .)) }}
{{- $secretData := dig "data" dict $secret }}
{{- if index $secretData "ENCRYPTION_KEY" }}
{{- index $secretData "ENCRYPTION_KEY" | b64dec -}}
{{- else if and $secret (hasKey $secret "data") }}
{{- fail (printf "Secret %q exists in namespace %q but is missing ENCRYPTION_KEY" (include "formbricks.appSecretName" .) .Release.Namespace) -}}
{{- if $secret }}
{{- index $secret.data "ENCRYPTION_KEY" | b64dec -}}
{{- else }}
{{- randAlphaNum 32 -}}
{{- end -}}
@@ -350,11 +342,8 @@ true
{{- define "formbricks.nextAuthSecret" -}}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "formbricks.appSecretName" .)) }}
{{- $secretData := dig "data" dict $secret }}
{{- if index $secretData "NEXTAUTH_SECRET" }}
{{- index $secretData "NEXTAUTH_SECRET" | b64dec -}}
{{- else if and $secret (hasKey $secret "data") }}
{{- fail (printf "Secret %q exists in namespace %q but is missing NEXTAUTH_SECRET" (include "formbricks.appSecretName" .) .Release.Namespace) -}}
{{- if $secret }}
{{- index $secret.data "NEXTAUTH_SECRET" | b64dec -}}
{{- else }}
{{- randAlphaNum 32 -}}
{{- end -}}
@@ -363,9 +352,8 @@ true
{{- define "formbricks.hubApiKey" -}}
{{- $hubSecretName := include "formbricks.hubSecretName" . }}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace $hubSecretName) }}
{{- $secretData := dig "data" dict $secret }}
{{- if index $secretData "HUB_API_KEY" }}
{{- index $secretData "HUB_API_KEY" | b64dec -}}
{{- if and $secret (index $secret.data "HUB_API_KEY") }}
{{- index $secret.data "HUB_API_KEY" | b64dec -}}
{{- else if .Values.hub.existingSecret }}
{{- fail (printf "hub.existingSecret %q must already exist in namespace %q and contain HUB_API_KEY when rendering the generated app secret. Disable secret.enabled and provide app-secrets externally, or pre-create the Hub secret." $hubSecretName .Release.Namespace) -}}
{{- else }}
@@ -375,9 +363,8 @@ true
{{- define "formbricks.cubejsApiSecret" -}}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "formbricks.appSecretName" .)) }}
{{- $secretData := dig "data" dict $secret }}
{{- if index $secretData "CUBEJS_API_SECRET" }}
{{- index $secretData "CUBEJS_API_SECRET" | b64dec -}}
{{- if and $secret (index $secret.data "CUBEJS_API_SECRET") }}
{{- index $secret.data "CUBEJS_API_SECRET" | b64dec -}}
{{- else }}
{{- randAlphaNum 32 -}}
{{- end -}}
+3 -3
View File
@@ -670,10 +670,10 @@ hub:
# Pinned by digest for immutable, reproducible deployments. When digest is set it takes
# precedence over tag, and deployment, init container, and migration job all resolve to the
# same immutable image. Update on each Hub release.
# Current digest corresponds to ghcr.io/formbricks/hub:0.4.0.
digest: "sha256:3971dd15fcce22d438ac916266ed72176052460a2bd91808e0995e894faba5bc"
# Current digest corresponds to ghcr.io/formbricks/hub:0.3.0.
digest: "sha256:6c39b1143527137e881df785a5b668625a1fe3edb05485bb5ded19f813c8de88"
# Tag is a fallback for dev/non-prod when digest is cleared; keep aligned with the digest above.
tag: "0.4.0"
tag: "0.3.0"
pullPolicy: IfNotPresent
# Optional override for the secret Hub reads from.
+3 -3
View File
@@ -97,7 +97,7 @@ services:
# Keep hub, hub-migrate, and any future hub-worker on the same tag — they share one image and
# drift breaks migrations or job processing.
hub-migrate:
image: ghcr.io/formbricks/hub:${HUB_IMAGE_TAG:-0.4.0}
image: ghcr.io/formbricks/hub:${HUB_IMAGE_TAG:-0.3.0}
restart: "no"
entrypoint: ["sh", "-c"]
command:
@@ -112,7 +112,7 @@ services:
# Formbricks Hub API (ghcr.io/formbricks/hub). Uses a dedicated local Hub database by default.
hub:
image: ghcr.io/formbricks/hub:${HUB_IMAGE_TAG:-0.4.0}
image: ghcr.io/formbricks/hub:${HUB_IMAGE_TAG:-0.3.0}
depends_on:
hub-migrate:
condition: service_completed_successfully
@@ -142,7 +142,7 @@ services:
# Hub worker processes async jobs enqueued by the API, including embeddings.
hub-worker:
image: ghcr.io/formbricks/hub:${HUB_IMAGE_TAG:-0.4.0}
image: ghcr.io/formbricks/hub:${HUB_IMAGE_TAG:-0.3.0}
depends_on:
hub-migrate:
condition: service_completed_successfully
+295 -730
View File
File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Before

Width:  |  Height:  |  Size: 174 KiB

After

Width:  |  Height:  |  Size: 174 KiB

-56
View File
@@ -1,56 +0,0 @@
---
title: "AI Features"
description: "How AI features are organized, hosted, and controlled in Formbricks."
icon: "sparkles"
---
<Note>
AI features are part of the [Enterprise Edition](/self-hosting/advanced/license).
</Note>
Formbricks ships a single organization-wide toggle that turns on AI-powered helpers across the app:
**Settings → Organization → General → Smart functionality (AI)**.
## AI Principles
1. **Always optional**: AI is disabled until your organization enables. You can run
Formbricks fully without AI.
2. **We separate AI**: We distinguish between:
- **Smart Functionality**: helps teams build and operate faster (for example
[AI Survey Translation](/surveys/general-features/multi-language-surveys#translate-with-ai) or [AI Chart Creation](/unify-feedback/dashboards-charts#ai-builder)).
- **Data AI**: features that work directly with your feedback data (creating embeddings for Unify Feedback semantic search).
### Privacy-first and self-hosted where possible
We prioritize self-hosted AI, especially for capabilities that process customer feedback data. Formbricks supports
AI in self-hosted and on-premise environments, and we prefer open-weight models whenever feasible.
For Unify Feedback semantic search, we host the embeddings model ourselves. This means feedback data is not shared
with third-party model providers for that capability, and your collected feedback/response data is never used as AI
training input.
## Model Hosting Status
- **Current Smart Functionality model**: Gemini 3.5 Flash hosted on Google Cloud Platform in Germany.
- **Current Embeddings model**: Alibaba GTE embeddings hosted by Formbricks in Germany.
- **In progress**: evaluation of self-hosted Kimi 2.5 to replace Gemini 3.5 Flash for Smart Functionality.
## AI Features by Category
### Smart Functionality
- **[AI Survey Translation](/surveys/general-features/multi-language-surveys#translate-with-ai)**:
auto-translate survey questions, options, and prompts into enabled languages.
- **[AI Chart Creation](/unify-feedback/dashboards-charts#ai-builder)**:
describe a chart in natural language and Formbricks generates the underlying query.
### Data AI
- **Embeddings creation**:
create embeddings for feedback records so they can be used for semantic search, clustering, and retrieval.
## Permissions
Only **Owners** and **Managers** can change the AI toggle. Other roles see a read-only state.
@@ -1,10 +0,0 @@
---
title: "AI Features"
description: "Enable AI-powered helpers like survey translation and AI chart creation."
icon: "sparkles"
sidebarTitle: "AI Features"
---
A single organization toggle unlocks AI-assisted survey translation and AI chart creation across the app. Requires `AI_PROVIDER`, `AI_MODEL`, and the matching provider credentials on the instance.
Read the full guide: [AI Features](/platform/features/ai-features).
@@ -1,10 +0,0 @@
---
title: "Dashboards & Charts"
description: "Visualize Feedback Records with charts and group them onto shareable dashboards."
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.
Read the full guide: [Dashboards & Charts](/unify-feedback/dashboards-charts).
@@ -1,10 +0,0 @@
---
title: "Unify Feedback"
description: "Consolidate feedback from surveys, CSVs, and APIs into one normalized store."
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.
Read the full guide: [Unify Feedback overview](/unify-feedback/overview).
@@ -30,7 +30,7 @@ This confirms the Google identity for the current deletion attempt, but it does
### How to connect your Formbricks instance to Google
{/* prettier-ignore-start */}
<!-- prettier-ignore-start -->
<Steps>
<Step title="Create a GCP Project">
@@ -95,4 +95,4 @@ This confirms the Google identity for the current deletion attempt, but it does
</Step>
</Steps>
{/* prettier-ignore-end */}
<!-- prettier-ignore-end -->
@@ -15,7 +15,7 @@ These variables are present inside your machine's docker-compose file. Restart t
For `AI_PROVIDER=google`, use a Gemini model ID such as `gemini-2.5-flash` together with Google Cloud credentials. Formbricks uses Google Cloud naming here, even though the underlying SDK still talks to Vertex AI endpoints for Gemini model access.
{/* prettier-ignore-start */}
<!-- prettier-ignore-start -->
| Variable | Description | Required | Default |
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
@@ -144,6 +144,6 @@ For Helm deployments, the chart deploys Cube by default (`cube.enabled: true`).
cluster instead, set `cube.enabled: false`, point `CUBEJS_API_URL` at your endpoint, and supply
`CUBEJS_API_SECRET` through your existing secret management setup.
{/* prettier-ignore-end */}
<!-- prettier-ignore-end -->
Note: If you want to configure something that is not possible via above, please open an issue on our GitHub repo here or reach out to us on Github Discussions and we'll try our best to work out a solution with you.
-36
View File
@@ -1,36 +0,0 @@
---
title: "Surveys"
description: "Design, distribute, and analyze surveys with Formbricks."
icon: "tablet-screen"
---
Formbricks surveys let you collect feedback from customers, users, and employees through link surveys, website surveys, and in-app surveys.
## Build
A drag-and-drop builder with conditional logic, recall, variables, hidden fields, multi-language support, and over a dozen question types. Surveys can be fully styled to match your brand.
## Distribute
Share surveys via a public link, embed them in your website, or trigger them in your web or mobile app. Use targeting, recontact rules, and quotas to control who sees a survey and when.
## Analyze
Each survey has a built-in summary view and response table. For cross-survey analytics, pipe responses into [Dashboards & Charts](/unify-feedback/dashboards-charts) to build custom visualizations and KPIs. Export to CSV/XLSX, or stream responses out via webhooks and the REST API.
## Next steps
<CardGroup cols={2}>
<Card title="Link Surveys" icon="link" href="/surveys/link-surveys/quickstart">
Share standalone survey links by email, chat, or QR code.
</Card>
<Card title="Website & App Surveys" icon="mobile" href="/surveys/website-app-surveys/quickstart">
Trigger surveys inside your website or app.
</Card>
<Card title="Question Types" icon="question" href="/surveys/question-type/free-text">
Browse every available question type.
</Card>
<Card title="Best Practices" icon="lightbulb" href="/surveys/best-practices/understanding-survey-types">
Templates and proven survey patterns.
</Card>
</CardGroup>
-55
View File
@@ -1,55 +0,0 @@
---
title: "Dashboards & Charts"
description: "Visualize Feedback Records and group charts onto shareable dashboards."
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.
## Charts
Charts live under **Workspace → Charts**. Each chart is a query plus a visualization config.
### Available chart types
| Type | When to use |
| --- | --- |
| **Area Chart** | Trend over time with magnitude emphasis (cumulative volume, NPS trend with shaded area). Default. |
| **Bar Chart** | Compare a measure across categories (responses per source, NPS by segment). |
| **Line Chart** | Trend over time without area fill (CSAT week-over-week, response rate). |
| **Pie Chart** | Share-of-total across a small set of categories (channel mix, sentiment split). |
| **Big Number** | Headline KPI for a dashboard (total responses, avg NPS, % positive). |
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.
### AI builder
Describe what you want in natural language ("Average NPS by month over the last 90 days") and Formbricks generates the Cube query for you. You can edit the result in the manual builder afterwards.
The AI builder requires **Smart functionality (AI)** to be enabled at the organization level and a configured AI provider. See [AI Features](/platform/features/ai-features).
## Dashboards
Dashboards live under **Workspace → Dashboards**. Each dashboard is a grid you can resize and arrange.
From a dashboard you can:
- **Create** a new chart inline and have it added automatically.
- **Add existing** charts.
- **Reorder and resize** widgets.
- **Duplicate** a chart for a quick variation.
- **Delete** a widget (the underlying chart stays in the workspace).
## Permissions
- Owners, Managers, and members with **Manage** or **Read & Write** access can create and edit dashboards and charts.
- Members with **Read** access can view dashboards and charts but cannot edit them.
## Requirements
- A Feedback Directory with records.
- Workspace access to that directory.
@@ -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 **Organization Settings → 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.
-50
View File
@@ -1,50 +0,0 @@
---
title: "Feedback Records"
description: "The normalized unit of feedback inside a Feedback Directory."
icon: "list-check"
---
A **Feedback Record** is one piece of feedback expressed in a normalized schema. Whether it came from a survey response, a CSV row, or an API push, it lands in the same shape so you can query everything together.
## The Feedback Record schema
Every record has the following fields. Required fields must be mapped by every source.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `submission_id` | string | Yes | Stable ID for the submission (e.g. `response_id`, `ticket_id`, `order_id`). Used for idempotent re-imports. |
| `collected_at` | timestamp | Yes | When the feedback was originally collected. |
| `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. |
| `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. |
| `field_group_id` | string | No | Groups related fields (matrix, ranking, grid questions). |
| `field_group_label` | string | No | Human-readable group label. |
| `value_text` | string | No | Text responses. |
| `value_number` | float64 | No | Numeric responses (ratings, NPS, CSAT). |
| `value_boolean` | boolean | No | Yes/no responses. |
| `value_date` | timestamp | No | Date responses. |
| `metadata` | jsonb | No | Free-form context (device, campaign, custom fields). |
| `language` | string | No | ISO 639-1 language code (`en`, `de`, `fr`, ...). |
| `user_id` | string | No | Anonymous user ID. Never store PII here. |
The right `value_*` field is set based on `field_type`. For example a `nps` field uses `value_number`, an open-text comment uses `value_text`.
## 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`.
From the table you can:
- **Filter** by directory, 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.
- **Add** a record manually via the "+ Add" button.
## Idempotent imports
Sources that re-ingest data (CSV uploads, API ingestions) use `submission_id` as the dedup key. Re-importing the same `submission_id` updates the existing record instead of creating a duplicate.
-54
View File
@@ -1,54 +0,0 @@
---
title: "Feedback Sources"
description: "Sources that bring feedback data into a Feedback Directory."
icon: "plug"
---
A **Source** defines how external data is mapped into Feedback Records inside a Feedback Directory. Manage them from **Unify → Sources**.
## Source types
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.
### 2. CSV Import
Upload a CSV (up to **2 MB** and **1,000 rows**) and Formbricks auto-suggests a column mapping based on common header names (`timestamp`, `response_id`, `rating`, `feedback_text`, ...). Required columns: `submission_id`, `field_id`, `field_type`, and the feedback value.
<Note>
Re-uploading a CSV with the same `submission_id` updates existing records instead of creating duplicates.
</Note>
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.
## Field mapping
For CSV and API sources, you map each source column or question to a Feedback Record field. CSV mapping suggests matches automatically with high/medium/low confidence based on header names.
For Formbricks Surveys, we handle the mapping internally - each question type is translated into the matching Hub `field_type`:
- Single-select, multi-select, dropdown → `categorical`
- NPS → `nps`
- Rating → `rating`
- CSAT → `csat`
- CES → `ces`
- Free text → `text`
- Number → `number`
- Date → `date`
- Boolean / consent → `boolean`
## Managing sources
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.
-15
View File
@@ -1,15 +0,0 @@
---
title: "Formbricks Hub"
description: "The data layer that powers Unify Feedback."
icon: "database"
---
**Formbricks Hub** is Formbricks' unified feedback data layer. It stores normalized feedback from multiple input channels so teams can query, analyze, and act on it in one place.
Unify Feedback is powered by Hub under the hood: Feedback Sources ingest data into Hub, Feedback Records are stored in Hub, and dashboards, charts, and topics build on that shared model.
Hub is built for **the age of AI**: each open-text feedback record is vectorized so semantic search, clustering, and retrieval work out of the box. Its event-based architecture also lets you enrich records with any model, provider, and custom metadata of your choice.
The Hub is fully open-source (Apache 2.0) and can be self-hosted.
To learn more, visit the Hub docs at [hub.formbricks.com](https://hub.formbricks.com) and the [Hub API Reference](https://hub.formbricks.com/api).
-38
View File
@@ -1,38 +0,0 @@
---
title: "Unify Feedback"
description: "Bring feedback from every source into one place and turn it into insights."
icon: "layer-group"
---
Unify Feedback is the part of Formbricks that consolidates feedback from across your stack into a single, queryable store. Survey responses, CSV imports, API ingestions, and tool-generated records all land in the same model so you can analyze them together.
## 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.
## 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").
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.
<CardGroup cols={2}>
<Card title="Feedback Directories" icon="folder-tree" href="/unify-feedback/feedback-directories">
Org-level buckets that group your feedback.
</Card>
<Card title="Feedback Records" icon="list-check" href="/unify-feedback/feedback-records">
The normalized unit of feedback inside a directory.
</Card>
<Card title="Feedback Sources" icon="plug" href="/unify-feedback/feedback-sources">
Connectors that bring data into a directory.
</Card>
<Card title="Dashboards & Charts" icon="chart-line" href="/unify-feedback/dashboards-charts">
Visualize and share feedback insights.
</Card>
</CardGroup>
<Note>
Unify Feedback is an enterprise feature. Enable it on Formbricks Cloud with a paid plan, or self-host with a license.
</Note>
-33
View File
@@ -1,33 +0,0 @@
---
title: "Topics & Subtopics (Preview)"
description: "Vector clustering of open-text feedback into Topics and Subtopics."
icon: "tags"
---
<Warning>
Topics & Subtopics is a **Preview** feature. The schema and UI may change.
</Warning>
Open-text feedback ("Why did you give this score?", support tickets, app reviews) is rich but hard to count. Topics & Subtopics uses AI to cluster free text into a two-level taxonomy you can filter, count, and trend over time.
## 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).
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.
- **Inspect** which records cluster under each Topic.
## Roadmap
- Manual edits to topic labels and assignments.
- Topic filters on Charts and Dashboards.
- Per-source topic confidence scoring.
## Requirements
- A Feedback Directory with records.
- Smart functionality (AI) enabled at the organization level. See [AI Features](/platform/features/ai-features).
+29
View File
@@ -0,0 +1,29 @@
---
title: "XM & Surveys"
description: "Learn how Formbricks helps you gather, analyse, and report experience data."
icon: "tablet-screen"
---
Experience Management is the practice of measuring and managing how a stakeholder group of an organization (customers, employees, patients, citizens, etc...) experience the products or services of the organization.
Historically, Experience Management has three steps:
1. **Gather** data
2. **Analyze** and report on the data
3. **Integrate** and automate to measure experiences at scale
## Gather data
The heart of Formbricks data gathering is a powerful yet user-friendly survey builder. With a simple drag-and-drop interface, you can add questions, set response options, handle variables, set up complex logic and manage quotas. Our surveys have a modern look & feel, can be fully customized to match your brand - all while keeping respondent data safe.
## Analytics & Report
Formbricks gives you clear analytics and insights to understand user responses. It organizes survey results into easy-to-read formats, helping you spot trends, identify issues, and find opportunities for improvement. You can export your data to .csv or .xlsx or pipe it to your data lake via API.
We're working on a fully compliant way to leverage AI to harvest insights from unstructured data as well as a comprehensive reporting feature.
## Integrate & Automate
Experience Management scales best, when it is automated. Webhooks and the comprehensive REST API make it fast and easy to build integrations into your existing tech stack. Formbricks also powers integrations for n8n, ActivePieces, Zapier and Make.com to build any flow that you need.
@@ -132,30 +132,6 @@ For link surveys, the translation delivery is dependent on the `lang` URL parame
---
## Translate with AI
Translating every question, option, and label by hand can take a while. If your organization has AI enabled, you can fill in missing translations in one click.
<Steps>
<Step title="Open the Manage Translations modal">
Inside the survey editor, switch to the language you want to translate into and open the **Manage Translations** modal.
</Step>
<Step title="Click 'Translate with AI'">
The button is enabled when there are empty fields in the selected target language. Formbricks translates all empty headlines, descriptions, choices, and button labels from the default language into the target language.
</Step>
<Step title="Review and edit">
AI-translated strings are filled into the editor like manual translations. Review them before publishing and tweak anything that needs a different tone or wording.
</Step>
</Steps>
<Note>
AI translation is an [Enterprise feature](/self-hosting/advanced/license) and requires **Smart functionality (AI)** to be enabled at the organization level. See [AI Features](/platform/features/ai-features).
</Note>
---
## RTL Language Support
Formbricks fully supports Right-to-Left (RTL) languages such as Arabic, Hebrew, Persian, and Urdu. When you add an RTL language to your survey, the survey interface automatically adjusts to display content from right to left.

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