Compare commits

..

330 Commits

Author SHA1 Message Date
Tiago Farto 19fb6126cc chore: update openapi example 2026-05-18 17:08:53 +00:00
Tiago Farto 49473f17e3 chore: always use language maps, allow filtering multiple languages 2026-05-18 17:05:44 +00:00
Tiago Farto 0df059adcd chore: remove version query param 2026-05-18 16:53:47 +00:00
Tiago Farto fb463f6fc4 chore: fix build; mcp related changes 2026-05-18 16:43:07 +00:00
Tiago Farto 311e49311b chore: assorted improvements 2026-05-18 15:31:51 +00:00
Tiago Farto ff7ac26ba5 Merge branch 'main' into chore/v3_get_survey 2026-05-18 15:09:18 +00:00
Matti Nannt bf4303cdb5 feat: make Cube a mandatory baseline dependency in v5 (#8042)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-05-18 13:38:35 +00:00
Matti Nannt b3debbf0f6 fix: translate footer links based on survey language (ENG-673) (#8018)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 12:36:16 +00:00
Bhagya Amarasinghe e2bf79ce6c fix: harden storage presigned URL issuance (#8021)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-05-18 11:41:25 +00:00
Tiago 1032702b65 fix: fix sso redirect while deleting account (#8039) 2026-05-18 11:28:08 +00:00
Tiago 10a3ac4dc6 chore: SSO deletion workflow simplification (#8009) 2026-05-18 11:42:16 +02:00
Dhruwang Jariwala eea7df81b4 fix: seed default contact attribute keys on workspace creation (ENG-929) (#8036)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 07:34:33 +00:00
Johannes c172e2a33c fix: use block terminology in conditional logic docs (#7942)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-18 06:57:46 +00:00
Harsh Bhat 9a5780d510 chore: A/B test reduce cog. load in question section (#7944)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2026-05-18 06:56:38 +00:00
Johannes e333d8ba02 docs: add custom CSS guide for website & app surveys (#8031)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-18 06:47:05 +00:00
Johannes 16463960ad fix: flag id for A/B test re onboarding step 1 (#8035) 2026-05-18 06:45:37 +00:00
Dhruwang Jariwala a9e6bd440d fix(a11y): add feedback source dialog cannot scroll on short screens (#8004) 2026-05-14 16:10:05 +05:30
Javi Aguilar 7c53e7deca fix: prevent duplicated charts in dashboards (#8002)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-05-14 14:37:49 +04:00
Dhruwang eaf6c889f8 fix(hub): replace missed FormbricksHub.APIError in deleteFeedbackRecord
PR #7992 converted four of the five `err instanceof FormbricksHub.APIError`
checks to `getErrorStatus(err)` and removed the `FormbricksHub` default
import, but left the inline check in `deleteFeedbackRecord`. Since the
import is gone, the orphan reference throws ReferenceError at runtime
(visible in SonarCloud's coverage run on `vitest run --coverage`).

Completes the conversion so all catch handlers use the duck-typed helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:45:06 +05:30
Dhruwang 365c8e88b7 Merge remote-tracking branch 'origin/epic/v5' into fix/eng-924-scrollable-dialog 2026-05-14 15:44:04 +05:30
Dhruwang 3486ab67d7 Removed unnecessary classes 2026-05-14 14:41:08 +05:30
Dhruwang Jariwala defd333d97 fix: refine saving icon and delete source dialog text (#8001) 2026-05-14 14:36:36 +05:30
Dhruwang Jariwala 0e7ea4637d feat(analytics): augment Cube measures with CSAT/CES + correctness fixes (ENG-804) (#7985) 2026-05-14 13:42:58 +05:30
Anshuman Pandey 0475232bad fix: fixes same survey responses from being reported as failures (#7992) 2026-05-14 10:55:51 +04:00
Javi Aguilar b656e94f07 fix(a11y): add feedback source dialog cannot scroll on short screens 2026-05-13 16:32:23 +02:00
Javi Aguilar d73e342028 fix missing dialog description 2026-05-13 15:37:03 +02:00
Javi Aguilar 0a09b68e08 fix: show only loading icon when in saving state 2026-05-13 15:33:49 +02:00
Dhruwang Jariwala 5f5860cb23 feat(unify): add delete option for feedback records (ENG-938) (#7991) 2026-05-13 15:56:55 +04:00
Anshuman Pandey b2a95d4cee fix: correct matrix/ranking feedback records (#7982) 2026-05-13 11:16:52 +04:00
Bhagya Amarasinghe 64b4e18c5a fix(helm): restore TEI float16 dtype (#7988) 2026-05-13 12:28:44 +05:30
Dhruwang Jariwala ae9c1e499a fix: add missing title in feedback directory (#7983) 2026-05-13 12:04:15 +05:30
Dhruwang 0a4e32b848 fix: test 2026-05-13 11:06:19 +05:30
Bhagya Amarasinghe daae319c7a fix(helm): restore TEI float16 dtype 2026-05-13 00:04:33 +05:30
Dhruwang 7d77ed04de refactor(analytics): drop unused dims, broaden CSAT and group-by surface
Remove sentiment, emotion, and the TopicsUnnested join from the Cube
schema, web registry, AI prompt, audit-allowlist, and i18n keys — these
required metadata enrichment that is no longer planned. Tests swap the
removed string dimensions for sourceType to keep coverage intact.

Add csatDissatisfiedCount and csatNeutralCount so dashboards can render
the standard satisfied/neutral/dissatisfied distribution alongside the
existing top-2-box metric.

Expose field_label, field_group_label, language, value_boolean,
value_date, created_at, and updated_at as Cube dimensions. fieldLabel
and fieldGroupLabel unlock "group by question" and the matrix/ranking
aggregations enabled by the recent composite-question PR; the others
round out coverage of the underlying feedback_records columns. Extend
FieldDefinition with a boolean type and matching filter operators.
2026-05-12 18:45:10 +05:30
Dhruwang Jariwala 5b70c99eb3 fix(dashboards): chart removal flow + add-charts dialog focus ring (ENG-901, ENG-903) (#7981) 2026-05-12 16:55:48 +05:30
Dhruwang 10c09f00a8 refactor(dashboards): address review on removeWidgetFromDashboard
- Drop the prisma.$transaction wrapper; find + delete is two sequential
  steps, doesn't need a transaction.
- Drop the redundant ResourceNotFoundError catch branch; the trailing
  `throw error` already lets it bubble.
- Let action-client infer ctx / parsedInput types.

Tests: cover the two catch branches (Prisma -> DatabaseError, unknown
rethrow) so the new function is fully line-covered.
2026-05-12 16:11:45 +05:30
Dhruwang 602ffd5bba feat(analytics): augment Cube measures with CSAT/CES + bug fixes (ENG-804)
Adds CSAT and CES measures, plus a couple of universal cross-type measures,
to the FeedbackRecords Cube. Also fixes correctness bugs in the existing NPS
measures and constrains the AI chart-query output to known measure / dimension
ids.

Measures added
- csatScore: % of answered CSAT responses rated 4 or 5 (top-2-box on 1-5).
- csatAverage: AVG of answered CSAT ratings.
- csatSatisfiedCount: count where field_type='csat' AND value_number >= 4.
- csatCount: count of answered CSAT responses.
- cesAverage: AVG of answered CES ratings (1-5 or 1-7 depending on the question).
- cesCount: count of answered CES responses.
- uniqueRespondents: countDistinct(user_id).
- uniqueResponses: countDistinct(submission_id).
- npsAverage: AVG of answered NPS ratings (replaces the old `averageScore`
  measure, which silently averaged every numeric value across all field types).

Bug fixes (existing NPS measures)
- promoterCount / passiveCount / detractorCount / npsScore now filter
  field_type = 'nps'. Before, any numeric value in the band was counted
  (so a CSAT 5/5 was lumped in as a detractor).
- npsScore now divides by *answered* NPS responses only (value_number IS NOT
  NULL); dismissed/abandoned NPS records no longer drag the score toward 0.
- npsScore returns NULL when no answered NPS responses exist instead of the
  literal 0, so empty-data days render as gaps rather than a misleading
  flat-zero line.
- Same NULL-safe denominator fix applied to csatScore.
- csatCount / cesCount now exclude dismissed (value_number IS NULL) responses
  so they match the score denominators.

Dimensions
- Renamed `npsValue` -> `valueNumber` (the underlying value_number column
  stores values for NPS, CSAT, CES, rating, and number field types).
- New `valueText` dimension for grouping by categorical / open-text answers.

AI chart generation
- ZGenerateAIQueryResponse now enum-constrains `measures`, `dimensions`,
  `timeDimensions.dimension`, and `filters.member` against the known ids
  derived from FEEDBACK_FIELDS. Vercel AI SDK + Gemini structured outputs
  enforce these at decoding time, so the model can no longer return invalid
  or hallucinated measure names.
- generateText now pins temperature: 0. Same prompt produces the same query.

Breaking changes
- Charts that referenced `FeedbackRecords.averageScore` need to switch to
  `FeedbackRecords.npsAverage`.
- Charts that referenced `FeedbackRecords.npsValue` need to switch to
  `FeedbackRecords.valueNumber`.

Pre-GA on epic/v5; no production charts are expected to be impacted yet.
2026-05-12 16:05:38 +05:30
Javi Aguilar 5f4f133dcb fix: add missing title in feedback directory 2026-05-12 11:07:02 +02:00
Dhruwang Jariwala 037b005d48 fix(charts): pie tooltip spacing + scroll to chart-name on empty save (ENG-914, ENG-916) (#7970) 2026-05-12 13:22:00 +05:30
Dhruwang ddd2d5e983 fix(dashboards): chart removal flow + add-charts dialog focus ring (ENG-901, ENG-903)
ENG-901: clicking Remove on a chart widget no longer enters dashboard edit mode
(which surfaced the rename input) and saving with the last widget removed no longer
surfaces a raw "widgets: Too small" zod error. Out of edit mode, Remove now goes
through a DeleteDialog -> dedicated removeWidgetFromDashboardAction. The batched
update path also allows an empty widgets array now that the lib already supports
deleting all widgets correctly.

ENG-903: add p-1 to AddExistingChartsDialog DialogBody so the focus ring on the
inner search input is no longer clipped by the body's overflow boundary.
2026-05-12 12:51:46 +05:30
Dhruwang Jariwala 6777b284b3 fix(a11y): large selects are not scrollable (#7963) 2026-05-12 12:08:38 +05:30
Anshuman Pandey c6282632e0 fix: fixes read only issues in feedback sources UI (#7974) 2026-05-12 10:20:30 +04:00
Dhruwang Jariwala f84c409bc4 feat: add beta badge to unify feedback navigation section (#7968) 2026-05-12 11:38:09 +05:30
Dhruwang Jariwala 98b475a2a4 fix(charts): time-range crash + sentiment/emotion/response-id cube column mismatches (ENG-907, ENG-906, ENG-915) (#7973) 2026-05-12 10:02:50 +04:00
Dhruwang c48474b943 chore(charts): drop sonar S6478 JSDoc on PieTooltipRow
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:31:38 +05:30
Anshuman Pandey 3c0d1e3fd7 fix: fixes connector modal UI errors (#7971) 2026-05-11 16:40:35 +04:00
Bhagya Amarasinghe 1f7a496967 fix(helm): stop forcing TEI float16 dtype (#7967) 2026-05-11 17:24:57 +05:30
Dhruwang 99e378ae2e chore: drop verbose doc comments on ChartDialogFooter props
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:44:15 +05:30
Dhruwang c6e39c3103 refactor(charts): hoist pie tooltip formatter to module scope (sonar S6478)
Same pattern as CartesianChart's ChartTooltipRow — a module-level
component that calls useTranslation internally, plus a thin formatter
function passed to ChartTooltipContent. Resolves the nested-component
warning.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:41:18 +05:30
Dhruwang 19472ca9d3 chore: inline form onSubmit to avoid deprecated FormEvent type alias
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:37:20 +05:30
Dhruwang 43feff0009 fix(charts): inline error styling for empty chart-name on submit
Switch from the native browser tooltip to the in-app inline error pattern:
red Label, isInvalid Input border, and helper text below the field. The
onInvalid handler suppresses the default tooltip and scrolls + focuses the
field via event.currentTarget — no ref needed. Typing clears the error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:36:45 +05:30
Dhruwang 507cec6958 feat(charts): show translated message on empty chart-name validation
Browsers default to "Please fill in this field"; use the existing
workspace.analysis.charts.please_enter_chart_name key so the message
matches the toast we surface elsewhere. Clear custom validity on change
so the form unblocks once the user types.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:27:52 +05:30
Dhruwang d874b65be9 refactor(charts): use native form validation for chart-name field (ENG-916)
Replace the imperative scrollIntoView + focus on Save click with a tiny
<form> wrapping the required Input. The Save button becomes type=submit
with form="create-chart-form", so the browser handles scroll, focus, and
the "Please fill in this field" tooltip natively when the name is empty.

ChartDialogFooter now accepts either onSaveClick (button mode) or formId
(submit-button mode). All sibling buttons in the footer are explicitly
type="button" so they don't accidentally submit the form.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:25:17 +05:30
Dhruwang Jariwala c159af1a26 fix: format toast (ENG-893) (#7966) 2026-05-11 16:22:24 +05:30
Dhruwang 3ce2ef6cf4 fix(charts): scroll/focus chart-name input when save is blocked by empty name (ENG-916)
Clicking Save Chart with the name field empty fired a toast but left the
modal scrolled wherever the user was (e.g. on the chart preview at the
bottom), so they couldn't see what was wrong. Scroll the name field into
view and focus it before delegating to the existing save handler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:20:57 +05:30
Dhruwang Jariwala baae6335c9 fix: validate workspace assignment on feedback directory create (ENG-900) (#7965)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-05-11 14:50:05 +04:00
Dhruwang 8e443db1f6 revert: drop YAxis integer-tick handling
The fractional-tick bar/line/area scenario isn't the bug we're shipping in
this PR. Keep only the pie tooltip spacing/formatting change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:13:40 +05:30
Dhruwang 18cd9afc2c fix(charts): space between value and name in pie tooltip
Returning [value, name] from the tooltip formatter rendered as two adjacent text nodes ("3Nps"). Return a fragment with an explicit space so it reads "3 Nps".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:08:22 +05:30
Dhruwang 680e1e1593 fix(charts): show integer y-axis ticks for COUNT measures and format pie tooltips
ENG-914

When a chart's measures are all whole numbers (e.g. COUNT grouped by a
dimension), Recharts was auto-generating fractional y-axis ticks like
0.5, 1.5, 2 — nonsensical for a discrete count. The pie tooltip also
rendered values via raw String() with no thousand separators.

- Add allValuesAreIntegers() + formatYAxisTick() to chart-utils.
- CartesianChart now passes allowDecimals={false} and a number-aware
  tickFormatter to YAxis when every measure value is an integer.
- ChartRenderer's pie tooltip routes the value through formatCellValue,
  matching the Cartesian tooltip behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:49:57 +05:30
Dhruwang 04bfdeef69 removed stale comment 2026-05-11 15:09:51 +05:30
Dhruwang 8a1db7b6aa reverted errorcode transform change 2026-05-11 15:08:58 +05:30
Dhruwang 1d22fe2da6 fix: surface translated inline form errors and prefer .at() over slice
- FormError ignored its children when an error was set, rendering the raw
  zod message instead of the translated text passed in. Prefer explicit
  children, fall back to the field error message.
- Use .at(-1) in getTranslatedFeedbackDirectoryError per SonarQube S7755.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:03:00 +05:30
Dhruwang d8a119712c revert: drop zero-workspace requirement on feedback directory create
Per Johannes on ENG-893: zero-workspace creation is acceptable — admins
move workspaces between directories and forcing at least one is an
arbitrary limitation. The unformatted-error fix
(getTranslatedFeedbackDirectoryError prefix stripping) is retained.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:54:54 +05:30
Cursor Agent 50089eeca4 feat: add beta badge to unify feedback section
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-11 08:20:06 +00:00
Bhagya Amarasinghe d8fe832f5f fix(helm): stop forcing TEI float16 dtype 2026-05-11 13:24:49 +05:30
Dhruwang 1c904e2243 fix: require at least one workspace when creating a feedback directory
ENG-893

Why: A feedback directory with zero workspaces is useless — it groups
nothing. The create path allowed it, and any validation error that did
fire surfaced as a raw "field: CODE" string in the toast.

How:
- createFeedbackDirectory now rejects empty/missing workspaceIds with
  DIRECTORY_WORKSPACES_REQUIRED before touching the database.
- getTranslatedFeedbackDirectoryError strips the "<field>: " prefix that
  next-safe-action prepends to validation errors, so machine codes always
  map to a translated message.
- Add the new translation key across all 15 locales.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:04:47 +05:30
Javi Aguilar 4dbecc2d58 fix/a11y-select-scroll 2026-05-11 05:52:55 +02:00
Dhruwang Jariwala 72f4e93432 fix: support Redis Cluster for BullMQ jobs (#7960) 2026-05-08 15:28:10 +05:30
Dhruwang Jariwala 9007502804 feat(feedback-sources): add a Create Survey CTA if there are none (#7943) 2026-05-08 15:10:28 +05:30
Tiago Farto d84589452c Merge branch 'epic/v5' of https://github.com/formbricks/formbricks into fix/investigate-bullmq-issue
# Conflicts:
#	docs/development/technical-handbook/background-job-processing.mdx
2026-05-08 09:38:53 +00:00
Tiago Farto 43aaed3923 fix: support Redis Cluster for BullMQ jobs 2026-05-08 09:19:49 +00:00
Bhagya Amarasinghe 550bfc6a6c fix: update Hub runtime defaults for v5 staging (#7959) 2026-05-07 19:25:20 +02:00
Bhagya Amarasinghe 2c22b00ec6 fix: address Cube chart review feedback (#7956) 2026-05-07 17:27:55 +02:00
Bhagya Amarasinghe d64fb546d3 feat: add internal cube helm deployment (#7955) 2026-05-07 16:06:24 +02:00
Dhruwang Jariwala f4ca7c46ef fix: add Hub and Cube env vars to Docker build secrets (#7950) 2026-05-07 17:22:05 +05:30
Dhruwang c252d8c4c9 fix: update tests for required Cube and Hub env vars
Tests now expect validation failures when CUBEJS_API_URL, CUBEJS_API_SECRET,
or HUB_API_KEY are missing, and all test env helpers provide HUB_API_KEY.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 17:14:01 +05:30
Dhruwang 2bec3b040d fix: remove unused ZOptionalUrl variable
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 17:06:23 +05:30
Dhruwang 3c49b33dad feat: make HUB_API_KEY required and add to Docker build secrets
Hub is mandatory in v5, so HUB_API_KEY should fail fast at startup
if not configured.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 16:56:55 +05:30
Dhruwang 0f2f3d337e fix: restore CUBEJS_JWT_AUDIENCE and CUBEJS_JWT_ISSUER in env schema
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 16:48:57 +05:30
Dhruwang 4d1df795ad feat: make CUBEJS_API_SECRET and CUBEJS_API_URL required
Makes Cube env vars mandatory in env.ts (per PR #7913) and adds them
as Docker build secrets with fallback values, following the same pattern
as DATABASE_URL, REDIS_URL, and HUB_API_URL.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 16:45:46 +05:30
Bhagya Amarasinghe 3ce2998d0d feat(helm): add Hub worker and embeddings runtime (#7945) 2026-05-07 16:35:32 +05:30
Bhagya Amarasinghe b9a6520e10 fix(helm): address embeddings review feedback 2026-05-07 16:21:42 +05:30
Dhruwang 55bb9a525e fix: use secrets.DUMMY_HUB_API_URL instead of hardcoded value
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 16:20:48 +05:30
Dhruwang 11055f812e fix: add HUB_API_URL to Docker build secrets
HUB_API_URL is required by the Zod env validation at build time but was
not provided as a Docker secret, causing the release build to fail.

Adds HUB_API_URL with a dummy fallback (http://localhost:4000) to the
build pipeline, following the same pattern as DATABASE_URL/REDIS_URL.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 16:18:35 +05:30
Anshuman Pandey ecf3aacca3 fix: removes auto feedback directory linking with workspaces (#7947) 2026-05-07 13:46:25 +04:00
Dhruwang Jariwala a0f3d2a651 chore: upgrade Hub to 0.3.0 and SDK to 0.5.0 (#7948) 2026-05-07 14:59:11 +05:30
Dhruwang 16bbd7a447 chore: upgrade Hub to 0.3.0 and SDK to 0.5.0
Hub 0.3.0 renames the `user_identifier` API field to `user_id` (breaking
change). This commit bumps the Hub Docker image, upgrades the
@formbricks/hub TypeScript SDK from 0.4.3 to 0.5.0, and renames every
`user_identifier` reference in Zod schemas, server actions, transform
pipeline, form components, CubeJS schema, connector types, and seed data
to match the new API contract.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 14:31:09 +05:30
Bhagya Amarasinghe a276aa6d34 fix(helm): default embeddings model to gte multilingual 2026-05-07 13:46:29 +05:30
Javi Aguilar d192fbf839 add CR changes 2026-05-07 10:12:06 +02:00
Javi Aguilar c5d52df9b7 use i18n interpolation properly 2026-05-07 10:12:06 +02:00
Javi Aguilar 550e859a2d feat(unify): add CTA to create a survey before using it as feedback source if there are none 2026-05-07 10:12:06 +02:00
Dhruwang Jariwala 6fb9cf28b1 fix: add cursor-based pagination and fix refresh for feedback records (#7935)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-05-07 10:13:59 +04:00
Dhruwang Jariwala 8c47cdba73 chore: drop explicit feedback directory grants, use implicit auth (#7941) 2026-05-07 10:24:30 +05:30
Bhagya Amarasinghe e6b6f5e6d3 feat(helm): add Hub worker and embeddings runtime 2026-05-07 01:45:45 +05:30
pandeymangg 6218153351 fixes tests 2026-05-06 16:33:03 +05:30
pandeymangg 9ef4be270b fix: removes feedback directory auth from api keys 2026-05-06 16:29:06 +05:30
Dhruwang Jariwala ed42df34c4 feat(ai): support Vertex AI ADC credentials (#7938) 2026-05-06 12:37:24 +05:30
Dhruwang Jariwala 8c8ff8e396 feat: gate AI chart generation behind all AI checks (#7937) 2026-05-06 10:09:49 +05:30
Dhruwang 72cf2d6a50 test: add coverage for getAIDataAnalysisUnavailableReason 2026-05-05 18:06:02 +05:30
Bhagya Amarasinghe c5d629ef25 feat(ai): support Vertex AI ADC credentials 2026-05-05 18:04:30 +05:30
Dhruwang 71cb8bdff5 refactor: extract getAIDataAnalysisUnavailableReason to shared utility
Move duplicated function to @/lib/ai/service and export TAIUnavailableReason
type. Remove local copies from charts-list-page and dashboard-detail-page.
2026-05-05 17:40:42 +05:30
Dhruwang 850fb8acc3 feat: gate AI chart generation behind all 3 AI checks
- Server-side: Replace hardcoded OpenAI with provider-agnostic `getAiModel(env)` and enforce
  `assertOrganizationAIConfigured(organizationId, "dataAnalysis")` which validates license
  entitlement, org-level toggle, and instance configuration
- Client-side: Instead of hiding AI section when unavailable, show it disabled with a tooltip
  explaining the reason (not in plan / not enabled / instance not configured), following the
  same pattern as AI translate
- Thread `isAIAvailable` and `aiUnavailableReason` through the component chain from server
  pages down to `AIQuerySection`
- Update test mocks to match new provider-agnostic AI imports
2026-05-05 17:21:22 +05:30
Dhruwang Jariwala 94c9e8fcf1 feat: gate Unify Feedback, FRDs, Dashboards behind license (#7924) 2026-05-05 17:15:14 +05:30
pandeymangg 49a8c8c686 adds nav links 2026-05-05 16:33:35 +05:30
pandeymangg 2832831db1 chore: merge with epic/v5 2026-05-05 16:21:34 +05:30
pandeymangg b5e6567194 fixes 2026-05-05 16:13:12 +05:30
Dhruwang Jariwala 86d3f2fae1 chore: hardening cube tenant isolation (#7920) 2026-05-05 16:03:11 +05:30
pandeymangg 62d09f6a8f chore: merge with epic/v5 2026-05-05 15:14:53 +05:30
Johannes 74dd778630 feat: similar feedback preview (#7917)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-05-05 13:28:08 +04:00
Tiago Farto 7ac99c0840 chore: update 2026-05-05 08:50:03 +00:00
Tiago Farto dde0f8d32c Merge branch 'epic/v5' into chore/harden-cube-tenant-isolation 2026-05-05 08:49:36 +00:00
Tiago Farto bcd3c91075 chore: address PR concerns 2026-05-05 08:39:56 +00:00
Bhagya Amarasinghe f376c620ab docs: align self-hosting docs for Formbricks v5 (#7906) 2026-05-05 12:52:18 +05:30
Bhagya Amarasinghe 4865a78338 docs: align v5 cube deployment guidance 2026-05-05 12:50:41 +05:30
Bhagya Amarasinghe a7c8e1acf9 docs: add MinIO to RustFS migration pointer 2026-05-05 12:47:02 +05:30
Bhagya Amarasinghe e5a097e56e docs: address CodeRabbit review feedback 2026-05-05 12:47:02 +05:30
Bhagya Amarasinghe 1ddde9cac7 docs: align self-hosting docs for Formbricks v5 2026-05-05 12:47:02 +05:30
Anshuman Pandey 59f5cdfb4b fix: hub pinned at specific tag/digest (#7923) 2026-05-05 11:15:41 +04:00
pandeymangg 8431eaf9f6 chore: merge with epic/v5 2026-05-05 11:38:39 +05:30
Dhruwang Jariwala f228e8e06a chore: Rename FeedbackRecordDirectory to FeedbackDirectory (#7925) 2026-05-05 09:15:20 +04:00
Dhruwang Jariwala 5e6ab81cb1 fix: migrate feedback-sources page to unified settings navigation (#7928) 2026-05-05 10:09:30 +05:30
Tiago Farto 1417a5a654 chore: restore document 2026-05-04 13:05:53 +00:00
Tiago Farto f8ae92b3be chore: remove doc 2026-05-04 13:04:37 +00:00
Dhruwang 1bc3f79f30 fix: translations 2026-05-04 18:25:11 +05:30
Dhruwang 7151dd5234 fix: migrate feedback-sources page to unified settings navigation
The feedback-sources page was still using the old WorkspaceConfigNavigation
(secondary tabs) instead of the new unified settings sidebar introduced in
#7904. This caused an inconsistent navigation experience.

Changes:
- Create new route at /settings/workspace/feedback-sources
- Add feedback-sources entry to SettingsSidebarContent
- Remove old WorkspaceConfigNavigation from ConnectorsSection
- Redirect old /feedback-sources route to new settings path
- Update all stale /feedback-sources links across the codebase
2026-05-04 18:20:54 +05:30
Dhruwang Jariwala 086315ce33 feat: unify settings UI with shared sidebar navigation (#7904) 2026-05-04 17:37:53 +05:30
Tiago Farto e01b4311ca chore: cleaned documentation duplication 2026-05-04 11:52:41 +00:00
Tiago Farto dd757394af fix: make bundled cube optional 2026-05-04 11:03:37 +00:00
Dhruwang 507f80f9b0 fix: update stale settings routes to match new /settings/{organization,workspace}/ structure
All internal links (billing, enterprise, general, api-keys, feedback-record-directories,
integrations) now point to their correct nested paths under /settings/organization/ or
/settings/workspace/. Also adds feedback-record-directories to the new sidebar nav with
the member visibility rule.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-04 16:31:25 +05:30
pandeymangg 8562232280 adds tests 2026-05-04 13:27:22 +05:30
pandeymangg 1234e6685a fixes feedback 2026-05-04 13:15:37 +05:30
pandeymangg 40a5e8ea6a fixes tests and i18n validation 2026-05-04 13:12:38 +05:30
pandeymangg 319a76a70d moves connectors, dashboards and frd to ee 2026-05-04 12:55:34 +05:30
Tiago Farto 2abf8e1d8c fix: log rejected cube tenant queries 2026-04-30 16:52:41 +00:00
Tiago Farto a985dc698b refactor: simplify cube query filter traversal 2026-04-30 16:50:55 +00:00
Tiago Farto 7b59a6300e fix: address cube tenant isolation review 2026-04-30 16:34:09 +00:00
Tiago Farto bf8b4079fd test: isolate cube env config test 2026-04-30 16:20:29 +00:00
Tiago Farto 5704bfbc03 chore: hardening cube tenant isolation 2026-04-30 16:00:08 +00:00
Dhruwang 0920ccf2c3 fix: remove unused isBilling prop and stale translation keys
- Remove isBilling from WorkspaceBreadcrumb/WorkspaceAndOrgSwitch prop chain
- Remove unused common.organization_settings and common.unify translation keys

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 18:24:32 +05:30
Dhruwang db0c9e7c55 fix: update E2E action tests to wait for user-actions URL
The tests were waiting for a redirect to app-connection that no longer
exists — user-actions is now a standalone page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 18:09:45 +05:30
Dhruwang ef87d899b9 fix: simplify dropdown menus and fix Connect Your App icon
- Replace individual settings items in workspace/org dropdowns with single Settings link
- Change Connect Your App icon from ListChecksIcon to UnplugIcon
- Remove unused code (isActiveOrganizationSetting, isActiveWorkspaceSetting, etc.)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 18:06:19 +05:30
Bhagya Amarasinghe ea92ef9fce feat: add FeedbackRecords Envoy gateway (#7818) 2026-04-30 17:17:05 +05:30
pandeymangg 778fc2acf1 fix 2026-04-30 16:36:09 +05:30
Dhruwang 2ffef36c89 fix: update E2E tests for user-actions route and Teams heading ambiguity
- action.spec.ts: navigate to user-actions page instead of app-connection
- organization.spec.ts: use level:1 to disambiguate "Teams" heading

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 16:30:22 +05:30
pandeymangg 1d6bda74df removes route test 2026-04-30 16:26:47 +05:30
pandeymangg 12ff0b7c0e sonar issue fix 2026-04-30 16:19:11 +05:30
Dhruwang fa1079bac1 fix: update E2E tests for renamed settings labels
- "Look & Feel" comments → "Appearance"
- "Members & Teams" heading assertion → "Teams"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 16:07:18 +05:30
Dhruwang 1403f0bb01 fix: pass missing isBilling prop to WorkspaceBreadcrumb
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 16:06:34 +05:30
Dhruwang c79553633f fix: use export...from to re-export default in user-actions routes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 16:04:04 +05:30
Bhagya Amarasinghe f16fb3b62f fix: restore required feedback record list params 2026-04-30 15:59:45 +05:30
Dhruwang 7dfc7f4825 docs: update references to renamed settings labels
- "Configuration" → "Settings"
- "Look & Feel" → "Appearance"
- "Website & App Connection" → "Connect Your App" / "User Actions"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 15:46:47 +05:30
Dhruwang 1ecc9f1722 fix: restore settings sidebar, rename labels, fix SonarQube issues, and extract user-actions page
- Restore settings sidebar in MainNavigation (lost during epic/v5 merge)
- Rename "Configuration" to "Settings", "Look & Feel" to "Appearance", etc.
- Fix SonarQube issues: duplicate class, regex injection, nested ternary, inline arrow functions
- Extract User Actions from Connect Your App into its own settings page
- Update all i18n translation keys across locales

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 15:41:50 +05:30
Dhruwang 7d1c02b54b Merge remote-tracking branch 'origin/epic/v5' into feat/settings-cleanup-v5 2026-04-30 14:56:35 +05:30
Bhagya Amarasinghe f2c452d7f9 feat: make cubejs mandatory for xm suite v5 (#7913) 2026-04-30 14:34:50 +05:30
Bhagya Amarasinghe afcfbb7a3a fix: address cube review follow-ups 2026-04-30 14:17:54 +05:30
Bhagya Amarasinghe 7f8c9dcbb8 chore: merge epic/v5 into feedback records gateway 2026-04-30 01:22:24 +05:30
Bhagya Amarasinghe 3998e4da31 fix: resolve sonar quality gate warning 2026-04-30 00:59:25 +05:30
Bhagya Amarasinghe 48086faffc fix: address cube review feedback 2026-04-30 00:39:49 +05:30
Bhagya Amarasinghe 38a0d7c810 Merge remote-tracking branch 'origin/epic/v5' into bhagya/eng-765-make-cubejs-mandatory-for-xm-suite-v5 2026-04-30 00:32:05 +05:30
Bhagya Amarasinghe b17bb88daa fix: require cube env vars in app config 2026-04-30 00:30:11 +05:30
Anshuman Pandey f59e9f13ec feat: refresh analysis charts and dashboard feedback gating (#7915)
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-29 16:29:14 +04:00
Anshuman Pandey 5169dec510 feat: wire workspace settings to feedback record directories (#7910)
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-29 14:49:50 +04:00
Bhagya Amarasinghe 0df16f6f0c feat: make cubejs mandatory for xm suite v5 2026-04-29 16:08:24 +05:30
Anshuman Pandey 8442dedf9c fix: removes project references (#7907) 2026-04-29 14:17:42 +04:00
Dhruwang 22c27c5ebb fix: remove unused params prop from notifications page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-29 15:27:50 +05:30
Dhruwang 6638dceb04 feat: disable settings for billing role, hide back button, add org switcher to landing sidebar
- Disable all workspace and select org settings items for billing-role users
- Hide the top bar (back button) for billing users in settings mode
- Add organization switcher with lazy-loaded org list to landing sidebar
- Pass isMultiOrgEnabled to landing sidebar for create-org option

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-29 15:24:42 +05:30
Dhruwang 8558121e46 feat: enhance SettingsSidebarContent with tooltip and popover for disabled items
- Added Tooltip and Popover components to provide user feedback for disabled navigation items.
- Implemented conditional rendering of tooltips and popovers based on item state.
- Introduced a disabledMessage prop to display appropriate messages for unauthorized actions.
2026-04-29 14:46:48 +05:30
Dhruwang f1279d51e5 fix: transaltions 2026-04-29 14:42:48 +05:30
Dhruwang 926706be9d fix: merge epic/v5, fix stale integration URLs and settings workspace switcher
- Resolve merge conflict in create-connector-modal (keep NoFeedbackRecordDirectoryAlert)
- Fix GoBackButton URLs in slack, google-sheets, airtable integration pages to use /settings/workspace/integrations path
- Fix connectHref values in integrations page (webhooks, google-sheets, airtable, slack, notion, JS SDK)
- Fix handleWorkspaceChange to stay in settings mode when switching workspace from settings sidebar

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-29 14:36:06 +05:30
Dhruwang 85b456e619 fix: navigate to surveys via URL in multi-language e2e test
The settings sidebar replaces the main nav, so the "Surveys" link is
not visible when on a settings page. Use direct URL navigation instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-29 13:46:34 +05:30
Bhagya Amarasinghe 3bac488a29 fix: address gateway review follow-ups 2026-04-29 12:27:41 +05:30
Johannes fbe2a31133 refactor: align connector enum with formbricks_survey (#7825)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-29 10:57:20 +04:00
Bhagya Amarasinghe 79d618f77c refactor: generalize gateway token minting 2026-04-29 12:16:08 +05:30
Anshuman Pandey 89eb04f813 fix: adds submission id to csv connector (#7898) 2026-04-29 10:36:16 +04:00
Dhruwang 8a2b349329 feat: unify settings under /workspaces/[id]/settings with shared sidebar navigation
Consolidate all settings (Account, Organization, Workspace) under a unified
URL structure with a shared sidebar that replaces the main navigation when
in settings mode. Remove horizontal nav bars, old dropdown-based navigation
patterns, and route group layouts in favor of real URL segments.

- Move workspace settings from /(workspace)/ to /settings/workspace/
- Move org settings from /settings/(organization)/ to /settings/organization/
- Move account settings from /settings/(account)/ to /settings/account/
- Add SettingsSidebarContent with inline workspace/org switchers
- Replace main sidebar with settings nav when pathname includes /settings
- Update all page headings to match sidebar nav labels
- Update e2e tests for new URL structure and navigation patterns
- Remove unused translation keys

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-28 18:26:58 +05:30
Dhruwang Jariwala a862b739f7 fix: consistent enabled/disabled wording for connector status (#7897) 2026-04-28 15:11:44 +05:30
Dhruwang 4e5df85538 fix: make pipeline dispatch fire-and-forget in management responses route
Pipeline errors (snapshot loading or dispatch) should not prevent the
201 response from being returned. Dispatch pipeline events without
awaiting so the response is returned immediately.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-28 14:31:24 +05:30
Dhruwang 727b349086 fix: resolve pre-existing build errors on epic/v5
- Add optional chaining for organization.billing in response pipeline
- Add missing feedbackRecordDirectoryId to Chart seed data

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-28 13:47:20 +05:30
Dhruwang f75db6b1d0 fix: translations 2026-04-28 12:39:12 +05:30
Dhruwang 7ffca53577 fix: use consistent enabled/disabled wording for connector status badges
The dropdown actions say "Enable"/"Disable" but the status badges showed
"In Progress"/"Paused". Now both use "Enabled"/"Disabled" for consistency.

Resolves ENG-769

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-28 12:24:49 +05:30
Dhruwang Jariwala 25614b23fc chore: remove legacy styling fields (questionColor, inputColor) (#7783) 2026-04-28 11:22:03 +05:30
Johannes 016e14d0f1 fix: (Depr Env QA) Surface legacy env var on Connection page (#7773) 2026-04-27 14:20:25 +00:00
Bhagya Amarasinghe be80db8418 fix: address envoy auth review findings 2026-04-27 19:31:43 +05:30
Bhagya Amarasinghe bcc3789ce8 refactor: generalize envoy auth dispatch 2026-04-27 18:31:58 +05:30
Dhruwang 5e76ebdfc1 fix: treat JSON null as absent in legacy styling migration
The `?` operator only checks key existence — if the form layer saved
`{"elementHeadlineColor": null}` (JSON null = "use default"), the
migration skipped the copy and then removed the legacy key, losing
the color value. Switch to COALESCE(styling->'field', 'null'::jsonb)
= 'null'::jsonb which catches both missing keys (SQL NULL) and JSON
null values.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-27 17:07:05 +05:30
Bhagya Amarasinghe 150f256721 fix: decouple pipeline from response ingestion (#7651) 2026-04-27 12:59:27 +05:30
pandeymangg da7971328c little cleanup 2026-04-27 12:58:42 +05:30
Bhagya Amarasinghe a6cd56b196 fix: migrate response pipeline to BullMQ (#7651) 2026-04-27 12:58:42 +05:30
pandeymangg 7c81cf119e adds test for schedulePipelineDrain when env vars are not set 2026-04-27 12:56:22 +05:30
Bhagya Amarasinghe 8d29b24352 fix: address latest pipeline review comments (#7651) 2026-04-27 12:56:22 +05:30
Bhagya Amarasinghe a1ae849496 fix: address CodeRabbit pipeline findings (#7651) 2026-04-27 12:56:22 +05:30
Bhagya Amarasinghe 4d0a686e89 fix: address pipeline PR checks (#7651) 2026-04-27 12:56:22 +05:30
Bhagya Amarasinghe 364915e4c8 fix: decouple pipeline from response ingestion (#1487) 2026-04-27 12:56:22 +05:30
Bhagya Amarasinghe ada2518d0c fix: address feedback records gateway build failures 2026-04-24 17:30:41 +05:30
Bhagya Amarasinghe 57d1c0ed99 fix: resolve feedback records PR check failures 2026-04-24 16:53:57 +05:30
Tiago 817b299436 chore: rename gcp ai provider to google (#7815) 2026-04-24 10:10:58 +00:00
Tiago Farto c140dae872 Merge branch 'epic/v5' into chore/rename_google 2026-04-24 09:51:39 +00:00
Bhagya Amarasinghe 6036a8c767 fix: harden FeedbackRecords Envoy auth routing 2026-04-24 13:51:54 +05:30
Dhruwang Jariwala bf592937f4 feat: AI-powered survey translation (#7793) 2026-04-24 12:55:36 +05:30
Bhagya Amarasinghe 1cfadd968a feat: add FeedbackRecords Envoy gateway 2026-04-24 02:17:54 +05:30
Tiago Farto 21ed383a46 chore: address PR concerns 2026-04-23 13:45:09 +00:00
Bhagya Amarasinghe 7ed7101ac1 feat: adds feedback record directory auth to api keys (#7804) 2026-04-23 18:04:17 +05:30
Tiago Farto 7aa12a4f0c chore: rename google ai things 2026-04-23 12:27:39 +00:00
pandeymangg 2e926936fb addressed feedback 2026-04-23 17:46:09 +05:30
Dhruwang 8edef8aede refactor: replace repeated union type with TDimension alias in TBaseStyling
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 16:22:13 +05:30
Dhruwang 54fb202285 fix: add jsonb_typeof guard to legacy styling migration
Ensures the UPDATE only processes JSONB objects, preventing errors
on unexpected scalar or array values in the styling column.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 16:16:50 +05:30
Dhruwang c720a462a7 fix: rename inputColor to inputTextColor in survey-ui storybook files
Aligns storybook story helpers and element stories with the legacy
field removal — inputColor → inputTextColor, mapping to
--fb-input-text-color CSS variable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 16:12:53 +05:30
Dhruwang a386451e6e refactor: consolidate imports in actions.ts
Merged the import statements for ZAITranslationField and translateFields from the translate-fields module to streamline the code structure.
2026-04-23 15:17:31 +05:30
Dhruwang f0bf111e7b fix: test 2026-04-23 15:08:18 +05:30
Dhruwang 8a57a5b74b addressed feedback 2026-04-23 15:05:16 +05:30
Dhruwang 434cb1d0d0 refactor: remove BullMQ background jobs from AI translation
Replace the async job queue + Redis polling pattern with a direct
server action call. The translation now runs synchronously inside
translateSurveyFieldsAction, removing the need for BullMQ job
definitions, processors, cache keys, and client-side polling logic.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 14:11:28 +05:30
pandeymangg 8bde75a9ff chore: merge with epic/v5 2026-04-23 11:06:54 +05:30
pandeymangg 6b880f29cb chore: merge with epic/v5 2026-04-23 11:00:25 +05:30
Dhruwang Jariwala 969c9834e5 fix: (Depr Env QA) update lang keys (#7786) 2026-04-23 10:57:47 +05:30
pandeymangg 5e33b7c9a4 fixes and e2e fixes 2026-04-23 10:55:20 +05:30
Dhruwang 230ea744fa fix: simplify workspace permissions assignment in ViewPermissionModal 2026-04-23 10:38:18 +05:30
Anshuman Pandey fae1fb8f96 fix: cleans up environmentId references (#7792) 2026-04-23 09:05:00 +04:00
Dhruwang eac35daed9 fix: transllations 2026-04-23 10:34:29 +05:30
Johannes 45accc1edb fix: align workspace naming in setup and email preview
Replace remaining environment wording with workspace terminology across setup flows, API key permissions, and email preview text, and switch the email Tailwind config to ESM so formatting hooks run under the current Node runtime.

Made-with: Cursor
2026-04-23 10:30:30 +05:30
Dhruwang Jariwala 02ebe8e9f8 fix: (Depr Env QA) update docs (#7784) 2026-04-23 10:23:23 +05:30
pandeymangg cae859e326 sonarcloud fixes 2026-04-22 17:15:19 +05:30
pandeymangg 5352d563b6 fixes and consistency 2026-04-22 17:03:18 +05:30
Anshuman Pandey 711f2bfe67 chore: restores feedback record directory changes to epic/v5 (#7806) 2026-04-22 15:22:15 +04:00
Dhruwang 6fcb5d39a2 fix: address code review feedback for AI translation
- Reuse ZAITranslationField from @formbricks/jobs instead of duplicating
  the schema locally in actions.ts; tighten sourceLanguage/targetLanguage
  validators with .min(1) to match the downstream job schema

- Guard against undefined translations in getAITranslationResultAction
  instead of using the unsafe `translations!` assertion — return
  "pending" status for malformed cache entries

- Use createCacheKey.custom("ai-translation", jobId) instead of raw
  template strings to follow cache key conventions

- Improve JSON parsing robustness: strip markdown code fences before
  attempting JSON.parse, log raw response on parse failures

- Add stale-request guard and error handling to the AI availability
  useEffect in language-view.tsx

- Replace shared pollingCancelledRef boolean with per-invocation Symbol
  token to prevent stale polling loops from clobbering state when the
  modal is reopened

- Track timeout explicitly with a timedOut flag so the "timed out" toast
  doesn't fire when polling was actually cancelled

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 16:01:57 +05:30
pandeymangg 1ed9859ee7 fixes tests 2026-04-22 15:58:02 +05:30
pandeymangg cd72a0a78d adds translations 2026-04-22 15:44:45 +05:30
pandeymangg 2b09795787 feat: adds frd auth to api keys 2026-04-22 15:43:33 +05:30
Dhruwang 2451acb9bd fix: AI translation security, error handling, and test coverage
- Add userId verification in getAITranslationResultAction (security)
- Use OperationNotAllowedError for auth failures
- Store failure marker in cache on last BullMQ attempt
- Make JSON parsing more robust (extract first {...} block)
- Add "keep modal open" hint to translating toast
- Add test coverage for process-ai-translation-job

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 15:18:54 +05:30
Dhruwang 14dcded91b Merge branch 'epic/v5' of https://github.com/formbricks/formbricks into feat/ai-survey-translation 2026-04-22 15:01:12 +05:30
Dhruwang 46062f91cd refactors 2026-04-22 14:50:46 +05:30
Dhruwang Jariwala ffd4478184 chore: merge epic/dashboards into epic/v5 (#7798)
Signed-off-by: gulshank0 <gulshanbahadur002@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Theodór Tómas <theodortomas@gmail.com>
Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
Co-authored-by: Bhagya Amarasinghe <b.sithumini@yahoo.com>
Co-authored-by: Chowdhury Tafsir Ahmed Siddiki <ctafsiras@gmail.com>
Co-authored-by: neila <40727091+neila@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Tiago <1585571+xernobyl@users.noreply.github.com>
Co-authored-by: Harsh Bhat <90265455+harshsbhat@users.noreply.github.com>
Co-authored-by: Harsh Bhat <harshbhat@Harshs-MacBook-Air.local>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Balázs Úr <balazs@urbalazs.hu>
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Gulshan <gulshanbahadur002@gmail.com>
Co-authored-by: Tiago Farto <tiago@formbricks.com>
Co-authored-by: Harsh Bhat <harsh121102@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 12:24:05 +04:00
Dhruwang 69da1862fa Merge latest epic/v5 (survey scheduling) into ai-translation branch
Resolve merge conflicts to combine AI translation and survey scheduling
features in jobs package, instrumentation, and tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 13:39:10 +05:30
Dhruwang c11d3241ab fix: test and translations 2026-04-22 12:59:27 +05:30
Tiago 3fb09a1a26 feat: survey scheduling (#7766) 2026-04-21 15:00:51 +00:00
Dhruwang 6efa449c10 feat: add AI-powered survey translation via BullMQ background jobs
Add a "Translate with AI" button to the Manage Translations modal that
auto-populates empty translation fields using the configured AI provider.
Translation runs as a BullMQ background job with results cached in Redis
and polled by the client.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 17:38:47 +05:30
Dhruwang 34b94689ca fix: address CodeRabbit review feedback on docs
- Fix broken API URLs in docs-feedback.mdx (remove /workspaces segment, fix https://, remove stray };)
- Add missing workspaceId path params to v2 spec (responses, displays, user)
- Remove environmentId from required arrays in v2 request schemas
- Fix stale terminology: environment→workspace in database-model, tenant-separation, tags, actions
- Fix broken link to removed test-environment page in webhooks.mdx
- Fix redundant "codebase" in naming-conventions description
- Use neutral hostname in audit-logging example
- Hyphenate "open-source" in license.mdx
- Consistent workspaceId formatting in wordpress.mdx
- Update link text to match anchor in actions.mdx
- Remove dual environmentId/workspaceId in headless-surveys example
- Fix stale <project_id> placeholder in headless-surveys
- Fix awkward Next.js card copy in framework-guides.mdx
- Clarify BC wording in v2 introduction.mdx

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 15:35:26 +05:30
Tiago Farto 901fac7e08 chore: fix env vars 2026-04-21 09:56:32 +00:00
Dhruwang 739c662863 chore: merge epic/v5 and resolve openapi.json conflicts
Accept epic/v5 removals (attribute-classes, people endpoints) and
re-apply workspaceId rename + deprecation notes + endpoint cleanup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 15:22:17 +05:30
Dhruwang 535974ff8a fix: align API specs with actual routes and add environment deprecation notes
- Restore v1 openapi.json from main with environmentId→workspaceId rename
- Remove 5 non-existent v1 endpoints and their orphaned MDX pages
- Update v1 descriptions from "environment" to "workspace" terminology
- Add environment deprecation notes to all v1 client API endpoints
- Remove 2 non-existent v2 client endpoints (contacts attributes, identify)
- Rename v2 project-teams → workspace-teams (path, operationIds, schema)
- Preserve environment deprecation notes in v2 spec

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 15:15:52 +05:30
Tiago Farto a8b97abe9a Merge remote-tracking branch 'origin/epic/v5' into feat/survey-scheduling
# Conflicts:
#	pnpm-lock.yaml
2026-04-21 09:37:41 +00:00
Johannes 28103604b4 fix: (Depr Env QA) api v1/me regression (#7761)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-21 13:26:01 +04:00
Tiago Farto b5a7e15386 chore: fix build 2026-04-21 09:07:45 +00:00
Tiago Farto fec4746d5d chore: address PR comments 2026-04-21 08:51:32 +00:00
pandeymangg 175323e7d9 chore: merge with main 2026-04-21 14:07:14 +05:30
Tiago Farto 6130737d51 chore: fix DST bug 2026-04-20 16:57:48 +00:00
Tiago Farto bf10a8d0b2 Merge branch 'epic/v5' into feat/survey-scheduling 2026-04-20 16:09:16 +00:00
Tiago Farto 612f8dceb8 chore: fix test 2026-04-20 15:50:38 +00:00
Tiago 0303f16db4 feat: BullMQ background jobs + response pipeline (#7779) 2026-04-20 15:30:20 +00:00
Tiago Farto 07635b160e chore: fix test; migration 2026-04-20 15:13:27 +00:00
Tiago Farto 9cfcffdb5e chore: bug fix; tests 2026-04-20 14:22:06 +00:00
Tiago Farto 02264ffc5f chore: build fix 2026-04-20 13:25:01 +00:00
Tiago Farto 7dde3edd8d chore: fix tests 2026-04-20 12:30:03 +00:00
Dhruwang 730ab6a609 fix: use valid hex colors in styles unit tests
Replace invalid fake hex values (e.g. "#btn-bg", "#headline-color") with
valid hex colors so isLight() and mixColor() don't throw. Add missing
inputTextColor to the survey styling test so --fb-placeholder-color is set.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 17:41:19 +05:30
Johannes 4304a7efd6 Rename Projects to Workspaces in docs 2026-04-20 13:56:27 +02:00
Tiago Farto a89d598f8d Merge branch 'epic/bullmq' into feat/survey-scheduling 2026-04-20 11:46:13 +00:00
Tiago Farto 6ff5af712f chore: clean tests 2026-04-20 11:35:47 +00:00
Anshuman Pandey 398ba79e7e feat: ces and csat questions (#7688)
Co-authored-by: Johannes <johannes@formbricks.com>
2026-04-20 15:33:26 +04:00
Dhruwang 4e75a57692 chore: remove legacy styling fields (questionColor, inputColor) for v5
Add a SQL migration that copies legacy coarse-grained styling fields
to granular equivalents (e.g. questionColor → elementHeadlineColor,
inputColor → inputBgColor) and strips the legacy keys from the JSONB.

Remove the runtime deriveNewFieldsFromLegacy() shim, all fallback
chains in CSS variable generation, and update types, schemas, tests,
and OpenAPI spec to reflect the new canonical field names.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 17:00:20 +05:30
Tiago Farto 5127de9de0 chore: revert CI action 2026-04-20 11:11:09 +00:00
Tiago Farto 2bf7788a1b Merge branch 'epic/bullmq' into feat/survey-scheduling 2026-04-20 11:07:33 +00:00
Tiago Farto ee8122778b chore: address PR comments 2026-04-20 10:43:32 +00:00
Tiago Farto 8aaa7ed9c0 chore: build fix 2026-04-20 10:00:06 +00:00
Johannes bc7c8c5715 remove environment ID andenv references 2026-04-20 11:40:33 +02:00
Dhruwang Jariwala ab1ea7a5ce fix: remove legacy API rewrites from next.config.mjs (#7764)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-20 13:38:30 +04:00
Tiago Farto 4f749355e0 chore: fix coverage test 2026-04-20 09:14:06 +00:00
Tiago Farto 18b60ddd35 chore: fix build 2026-04-20 08:52:59 +00:00
Tiago Farto 87f1b01c7a chore: fix broken tests 2026-04-20 08:40:44 +00:00
Tiago Farto 851ea0deb2 chore: fix broken lock 2026-04-20 08:32:45 +00:00
pandeymangg 9abbbfdd35 chore: merge with main 2026-04-20 13:07:36 +05:30
Johannes 990c0eee31 refined UX 2026-04-19 16:05:29 +02:00
Tiago Farto 07f16b8a43 chore: fix build 2026-04-17 23:23:51 +00:00
Tiago Farto bf556b0608 chore: fix linting 2026-04-17 22:26:57 +00:00
Tiago Farto 8b0766a46e chore: bix fuild 2026-04-17 22:16:17 +00:00
Tiago Farto 1f995d6e25 chore: build fix 2026-04-17 20:05:11 +00:00
Tiago Farto 975a4d57f8 chore: fix build 2026-04-17 19:50:23 +00:00
Tiago Farto 69bd576fc5 chore: fix build 2026-04-17 16:37:22 +00:00
Tiago Farto a2e4a3bbd7 chore: fix build 2026-04-17 16:27:18 +00:00
Tiago Farto 281f854332 chore: address PR comments 2026-04-17 15:36:12 +00:00
Tiago Farto 24496774a5 chore: fix build 2026-04-17 14:57:55 +00:00
Tiago Farto aeaf3215b4 chore: fix 2026-04-17 14:51:51 +00:00
Tiago Farto f4c5162590 Merge epic/bullmq into feat/survey-scheduling 2026-04-17 14:47:05 +00:00
Tiago Farto dedb7389f0 Merge origin/epic/v5 into epic/bullmq 2026-04-17 14:33:21 +00:00
Tiago Farto 7aed1b84de chore: translations, fixes 2026-04-17 11:59:17 +00:00
Bhagya Amarasinghe 9d2e988c59 feat: remove app rate limits for Envoy-covered routes (#7714)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-17 12:43:22 +04:00
Tiago 31d2ea7444 chore: Move Response Pipeline to BullMQ (#7695) 2026-04-15 10:12:41 +03:00
pandeymangg 3da7129413 fixes tests 2026-04-14 17:09:13 +05:30
pandeymangg 75fbb23190 chore: merge with main 2026-04-14 17:01:17 +05:30
Tiago Farto d361c334d3 chore: fixed management snapshot gap 2026-04-13 14:28:31 +03:00
Tiago Farto a4d808b479 chore: build fix 2026-04-13 13:10:33 +03:00
Tiago Farto 18ae1748d3 chore: address PR comments 2026-04-13 12:50:21 +03:00
Dhruwang Jariwala 60f6ca9463 chore: deprecate environments (#7693)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-10 09:13:47 +04:00
Tiago Farto 3404e0c494 chore: fix string date convertion error 2026-04-09 17:14:14 +03:00
Tiago Farto 83499ae552 chore: fix build 2026-04-09 15:14:44 +03:00
Tiago Farto 2ac0c1eb07 chore: refactor 2026-04-09 15:04:31 +03:00
Tiago Farto 54ede3015e chore: fix build 2026-04-09 14:09:46 +03:00
Tiago Farto 1b4f05a062 chore: fix linting issue 2026-04-09 13:59:34 +03:00
Tiago Farto 197dbf5aa6 chore: address pr comments 2026-04-09 13:45:32 +03:00
pandeymangg aa27d242bb chore: merge with main 2026-04-09 15:26:30 +05:30
Tiago 7ca52a7a93 feat: Introduce BullMQ setup to Formbricks (#7684) 2026-04-09 11:47:58 +03:00
Tiago Farto 4a48839d17 Merge branch 'feat/background_workers_v1' into chore/response-to-bullmq 2026-04-09 11:43:30 +03:00
Tiago Farto 92bd9bdac7 chore: address PR comments 2026-04-09 11:26:12 +03:00
Tiago Farto ad4b6f8b8c chore: addressing additional PR comments 2026-04-09 10:39:01 +03:00
Tiago Farto 8de5079db3 chore: lint fix 2026-04-09 10:07:29 +03:00
Tiago Farto a60206dd44 chore: fix sonarqube warnings 2026-04-09 09:59:09 +03:00
Tiago Farto d66abdcdaf chore: refactoring 2026-04-09 09:26:38 +03:00
Tiago Farto 03fa41a911 fix: tighten v2 response validation details typing 2026-04-08 23:23:37 +03:00
Tiago Farto cab438e474 chore: refactor 2026-04-08 21:47:15 +03:00
Tiago Farto a6dfe78c81 fix: restore response pipeline safety guards 2026-04-08 20:47:47 +03:00
Tiago Farto e4d96f4379 fix: resolve jobs runtime type import for web build 2026-04-08 17:16:17 +03:00
Tiago Farto 581a66b4a9 chore: fix problems 2026-04-08 17:00:36 +03:00
Tiago Farto 5cf0c15812 chore: response to bullmq 2026-04-08 14:43:50 +03:00
Tiago Farto ebaa2d363c chore: fix flaky test 2026-04-08 10:25:48 +03:00
Tiago Farto 597ea40b75 chore: fix linting issues 2026-04-08 10:16:24 +03:00
Tiago Farto 3c39dcc2de chore: increased test coverage 2026-04-08 09:51:58 +03:00
Tiago Farto e8df1dbb35 chore: fix sonarqube warning 2026-04-07 22:15:10 +03:00
Tiago Farto 84987ce557 chore: linter fixes 2026-04-07 21:42:23 +03:00
Tiago Farto 784ed855d7 chore: additional tests; address PR comments 2026-04-07 21:14:52 +03:00
Tiago Farto 5a17d4144d fix: normalize storage result typing for web build 2026-04-07 19:07:15 +03:00
Tiago Farto 65c9db86c6 fix: separate storage type exports and imports 2026-04-07 18:04:27 +03:00
Tiago Farto bc94d34d1e fix: narrow storage route results by property 2026-04-07 17:41:13 +03:00
Tiago Farto 22be60a0ba fix: align storage type exports for web build 2026-04-07 17:18:53 +03:00
Tiago Farto a384963863 fix: type storage delete wrappers 2026-04-07 16:34:51 +03:00
Tiago Farto c067ae73bb fix: narrow storage delete result in route 2026-04-07 16:25:36 +03:00
Tiago Farto dc78a30cbe fix: repair pnpm lockfile for BullMQ branch 2026-04-07 16:13:17 +03:00
Tiago Farto 9c9ae8a3a2 test: fix env test on v5 branch 2026-04-07 16:01:21 +03:00
Tiago Farto 29a08151aa chore: addressed PR concerns 2026-04-07 15:59:20 +03:00
Tiago Farto f42a8822a9 chore: background workers trough bullMQ 2026-04-07 15:56:12 +03:00
Dhruwang Jariwala a771ae189a refactor: rename Project to Workspace across entire codebase (#7620) 2026-03-31 17:01:17 +05:30
Anshuman Pandey 029e069af6 feat: feedback record directories (#7592) 2026-03-27 04:18:20 -07:00
Matti Nannt 81272b96e1 feat: port hub xm-suite config to epic/v5 (#7578) 2026-03-25 11:04:42 +00:00
97 changed files with 3113 additions and 2166 deletions
+8 -12
View File
@@ -76,12 +76,10 @@ HUB_DATABASE_URL=postgresql://postgres:postgres@postgres:5432/hub?sslmode=disabl
# EMBEDDING_MODEL=gemini-embedding-001
# EMBEDDING_PROVIDER_API_KEY=
###########################
# CUBE ANALYTICS (XM V5) #
###########################
# XM Suite v5 analysis features require Cube.js. The optional xm dev profile exposes Cube on port 4000.
# Uncomment COMPOSE_PROFILES=xm to run the optional Cube analytics service.
# COMPOSE_PROFILES=xm
####################
# CUBE ANALYTICS #
####################
# Cube semantic-layer API. Required. The bundled Docker stack exposes Cube on port 4000.
CUBEJS_API_URL=http://localhost:4000
# Generate with: openssl rand -hex 32. `pnpm dev:setup` will create/preserve this automatically.
CUBEJS_API_SECRET=
@@ -157,11 +155,12 @@ PASSWORD_RESET_DISABLED=1
# INVITE_DISABLED=1
###########################################
# Account deletion reauthentication #
# Account deletion SSO confirmation #
###########################################
# Danger: disables fresh SSO reauthentication for passwordless account deletion. Keep unset unless you accept the risk.
# DISABLE_ACCOUNT_DELETION_SSO_REAUTH=1
# Danger: skips the SSO identity confirmation redirect for passwordless account deletion.
# Users can delete SSO accounts with only the in-app email text confirmation. Keep unset unless you accept the risk.
# DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION=1
##########
@@ -189,9 +188,6 @@ GITHUB_SECRET=
# Configure Google Login
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Google only returns the auth_time proof after Auth Platform Security Bundle "Session age claims" is enabled.
# Keep this unset until that setting is active for the OAuth app.
# GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED=1
# Configure Azure Active Directory Login
AZUREAD_CLIENT_ID=
@@ -25,7 +25,7 @@ const Page = async (props: ModePageProps) => {
}
const experimentVariant =
(await getPostHogFeatureFlag(session.user.id, "onboarding-mode-experiment")) || "control";
(await getPostHogFeatureFlag(session.user.id, "a-b_onboarding_skip-first-screen")) || "control";
if (experimentVariant === "remove-cx-and-surveys-mode") {
return redirect(`/organizations/${params.organizationId}/workspaces/new/channel`);
@@ -8,8 +8,8 @@ import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import { DeleteAccountModal } from "@/modules/account/components/DeleteAccountModal";
import {
ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE,
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE,
} from "@/modules/account/constants";
import { Button } from "@/modules/ui/components/button";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
@@ -22,6 +22,7 @@ interface DeleteAccountProps {
accountDeletionError?: string | string[];
isMultiOrgEnabled: boolean;
requiresPasswordConfirmation: boolean;
isSsoIdentityConfirmationDisabled: boolean;
}
export const DeleteAccount = ({
@@ -32,6 +33,7 @@ export const DeleteAccount = ({
accountDeletionError,
isMultiOrgEnabled,
requiresPasswordConfirmation,
isSsoIdentityConfirmationDisabled,
}: Readonly<DeleteAccountProps>) => {
const [isModalOpen, setModalOpen] = useState(false);
const isDeleteDisabled = !isMultiOrgEnabled && organizationsWithSingleOwner.length > 0;
@@ -42,21 +44,18 @@ export const DeleteAccount = ({
const hasShownAccountDeletionError = useRef(false);
useEffect(() => {
if (!accountDeletionErrorCode || hasShownAccountDeletionError.current) {
if (
accountDeletionErrorCode !== ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE ||
hasShownAccountDeletionError.current
) {
return;
}
hasShownAccountDeletionError.current = true;
if (accountDeletionErrorCode === ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE) {
toast.error(t("workspace.settings.profile.google_sso_account_deletion_requires_setup"), {
id: "account-deletion-sso-reauth-error",
});
} else {
toast.error(t("workspace.settings.profile.sso_reauthentication_failed"), {
id: "account-deletion-sso-reauth-error",
});
}
toast.error(t("workspace.settings.profile.sso_identity_confirmation_failed"), {
id: "account-deletion-sso-confirmation-error",
});
const url = new URL(globalThis.location.href);
url.searchParams.delete(ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM);
@@ -76,6 +75,7 @@ export const DeleteAccount = ({
user={user}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
organizationsWithSingleOwner={organizationsWithSingleOwner}
isSsoIdentityConfirmationDisabled={isSsoIdentityConfirmationDisabled}
/>
<p className="text-sm text-slate-700">
<strong>{t("workspace.settings.profile.warning_cannot_undo")}</strong>
@@ -4,6 +4,7 @@ import { DeleteAccount } from "@/app/(app)/workspaces/[workspaceId]/settings/acc
import { EditProfileDetailsForm } from "@/app/(app)/workspaces/[workspaceId]/settings/account/profile/components/EditProfileDetailsForm";
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
import {
DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION,
EMAIL_VERIFICATION_DISABLED,
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
IS_FORMBRICKS_CLOUD,
@@ -98,6 +99,7 @@ const Page = async (props: {
isMultiOrgEnabled={isMultiOrgEnabled}
accountDeletionError={searchParams.accountDeletionError}
requiresPasswordConfirmation={requiresPasswordConfirmation}
isSsoIdentityConfirmationDisabled={DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION}
/>
</SettingsCard>
<IdBadge id={user.id} label={t("common.profile_id")} variant="column" />
@@ -5,35 +5,29 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TSurvey, TSurveyElementSummaryOpenText } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact";
import { renderHyperlinkedContent } from "@/modules/analysis/utils";
import { PersonAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
import { ElementSummaryHeader } from "./ElementSummaryHeader";
import { ResponseSampleModal } from "./ResponseSampleModal";
interface OpenTextSummaryProps {
elementSummary: TSurveyElementSummaryOpenText;
survey: TSurvey;
locale: TUserLocale;
isReadOnly: boolean;
}
export const OpenTextSummary = ({
elementSummary,
survey,
locale,
isReadOnly,
}: Readonly<OpenTextSummaryProps>) => {
export const OpenTextSummary = ({ elementSummary, survey, locale }: OpenTextSummaryProps) => {
const { t } = useTranslation();
const { workspace } = useWorkspace();
const [visibleResponses, setVisibleResponses] = useState(10);
const [selectedResponseId, setSelectedResponseId] = useState<string | null>(null);
const handleLoadMore = () => {
// Increase the number of visible responses by 10, not exceeding the total number of responses
setVisibleResponses((prevVisibleResponses) =>
Math.min(prevVisibleResponses + 10, elementSummary.samples.length)
);
@@ -54,31 +48,17 @@ export const OpenTextSummary = ({
<TableRow>
<TableHead className="w-1/4">{t("common.user")}</TableHead>
<TableHead className="w-2/4">{t("common.response")}</TableHead>
<TableHead className="w-1/6">{t("common.time")}</TableHead>
<TableHead className="w-1/6">{t("common.response_id")}</TableHead>
<TableHead className="w-1/4">{t("common.time")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{elementSummary.samples.slice(0, visibleResponses).map((response) => (
<TableRow
key={response.id}
role="button"
tabIndex={0}
aria-label={t("workspace.surveys.summary.open_response_details")}
className="cursor-pointer hover:bg-slate-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-400"
onClick={() => setSelectedResponseId(response.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setSelectedResponseId(response.id);
}
}}>
<TableRow key={response.id}>
<TableCell className="w-1/4">
{response.contact ? (
<Link
className="ph-no-capture group flex items-center"
href={`/workspaces/${survey.workspaceId}/contacts/${response.contact.id}`}
onClick={(e) => e.stopPropagation()}>
href={`/workspaces/${workspace?.id}/contacts/${response.contact.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.contact.id} />
</div>
@@ -100,12 +80,9 @@ export const OpenTextSummary = ({
? renderHyperlinkedContent(response.value)
: response.value}
</TableCell>
<TableCell className="w-1/6">
<TableCell className="w-1/4">
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
</TableCell>
<TableCell className="w-1/6" onClick={(e) => e.stopPropagation()}>
<IdBadge id={response.id} />
</TableCell>
</TableRow>
))}
</TableBody>
@@ -119,14 +96,6 @@ export const OpenTextSummary = ({
)}
</div>
)}
<ResponseSampleModal
responseId={selectedResponseId}
onClose={() => setSelectedResponseId(null)}
survey={survey}
isReadOnly={isReadOnly}
locale={locale}
/>
</div>
);
};
@@ -1,144 +0,0 @@
"use client";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import { useEffect, useRef, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TResponseWithQuotas } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags";
import { TUserLocale } from "@formbricks/types/user";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { SingleResponseCard } from "@/modules/analysis/components/SingleResponseCard";
import {
getResponseAction,
getTagsByWorkspaceIdAction,
} from "@/modules/analysis/components/SingleResponseCard/actions";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
interface ResponseSampleModalProps {
responseId: string | null;
onClose: () => void;
survey: TSurvey;
isReadOnly: boolean;
locale: TUserLocale;
}
export const ResponseSampleModal = ({
responseId,
onClose,
survey,
isReadOnly,
locale,
}: Readonly<ResponseSampleModalProps>) => {
const { t } = useTranslation();
const [response, setResponse] = useState<TResponseWithQuotas | null>(null);
const [tags, setTags] = useState<TTag[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Cache fetched data per response ID to avoid re-fetching on re-open
const cache = useRef<Map<string, { response: TResponseWithQuotas; tags: TTag[] }>>(new Map());
// Track the in-flight request so stale resolutions can be ignored when the user
// switches rows quickly.
const latestRequestId = useRef<string | null>(null);
useEffect(() => {
if (!responseId) return;
const cached = cache.current.get(responseId);
if (cached) {
setResponse(cached.response);
setTags(cached.tags);
setErrorMessage(null);
return;
}
latestRequestId.current = responseId;
setIsLoading(true);
setResponse(null);
setErrorMessage(null);
Promise.all([
getResponseAction({ responseId }),
getTagsByWorkspaceIdAction({ workspaceId: survey.workspaceId }),
])
.then(([responseResult, tagsResult]) => {
// Discard if a newer request has started or the modal has been closed.
if (latestRequestId.current !== responseId) return;
const responseError = getFormattedErrorMessage(responseResult);
const tagsError = getFormattedErrorMessage(tagsResult);
const fetchedResponse = responseResult?.data ?? null;
const fetchedTags = tagsResult?.data ?? [];
if (responseError || tagsError || !fetchedResponse) {
const message = responseError || tagsError || t("common.something_went_wrong");
toast.error(message);
setErrorMessage(message);
return;
}
const entry = { response: fetchedResponse, tags: fetchedTags };
cache.current.set(responseId, entry);
setResponse(entry.response);
setTags(entry.tags);
})
.catch(() => {
if (latestRequestId.current !== responseId) return;
const message = t("common.something_went_wrong");
toast.error(message);
setErrorMessage(message);
})
.finally(() => {
if (latestRequestId.current !== responseId) return;
setIsLoading(false);
});
}, [responseId, survey.workspaceId, t]);
const handleOpenChange = (open: boolean) => {
if (!open) {
// Drop any in-flight request so it can't commit after close.
latestRequestId.current = null;
setErrorMessage(null);
onClose();
}
};
return (
<Dialog open={!!responseId} onOpenChange={handleOpenChange}>
<DialogContent width="wide">
<VisuallyHidden asChild>
<DialogTitle>{t("common.response")}</DialogTitle>
</VisuallyHidden>
<VisuallyHidden asChild>
<DialogDescription>{t("common.response")}</DialogDescription>
</VisuallyHidden>
<DialogBody>
{isLoading ? (
<div className="py-12">
<LoadingSpinner />
</div>
) : errorMessage ? (
<div className="py-12 text-center text-sm text-slate-600">{errorMessage}</div>
) : response ? (
<SingleResponseCard
survey={survey}
response={response}
environmentTags={tags}
isReadOnly={isReadOnly}
locale={locale}
/>
) : null}
</DialogBody>
</DialogContent>
</Dialog>
);
};
@@ -41,16 +41,9 @@ interface SummaryListProps {
responseCount: number | null;
survey: TSurvey;
locale: TUserLocale;
isReadOnly: boolean;
}
export const SummaryList = ({
summary,
responseCount,
survey,
locale,
isReadOnly,
}: Readonly<SummaryListProps>) => {
export const SummaryList = ({ summary, responseCount, survey, locale }: SummaryListProps) => {
const { workspace } = useWorkspaceContext();
const { setSelectedFilter, selectedFilter } = useResponseFilter();
const { t } = useTranslation();
@@ -123,7 +116,6 @@ export const SummaryList = ({
elementSummary={elementSummary}
survey={survey}
locale={locale}
isReadOnly={isReadOnly}
/>
);
}
@@ -49,7 +49,6 @@ interface SummaryPageProps {
locale: TUserLocale;
initialSurveySummary?: TSurveySummary;
isQuotasAllowed: boolean;
isReadOnly: boolean;
}
export const SummaryPage = ({
@@ -58,8 +57,7 @@ export const SummaryPage = ({
locale,
initialSurveySummary,
isQuotasAllowed,
isReadOnly,
}: Readonly<SummaryPageProps>) => {
}: SummaryPageProps) => {
const { t } = useTranslation();
const searchParams = useSearchParams();
@@ -227,7 +225,6 @@ export const SummaryPage = ({
responseCount={surveySummary.meta.totalResponses}
survey={surveyMemoized}
locale={locale}
isReadOnly={isReadOnly}
/>
</>
);
@@ -22,7 +22,7 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
import { PageHeader } from "@/modules/ui/components/page-header";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
const SurveyPage = async (props: Readonly<{ params: Promise<{ workspaceId: string; surveyId: string }> }>) => {
const SurveyPage = async (props: { params: Promise<{ workspaceId: string; surveyId: string }> }) => {
const params = await props.params;
const t = await getTranslate();
@@ -88,7 +88,6 @@ const SurveyPage = async (props: Readonly<{ params: Promise<{ workspaceId: strin
locale={user.locale ?? DEFAULT_LOCALE}
initialSurveySummary={initialSurveySummary}
isQuotasAllowed={isQuotasAllowed}
isReadOnly={isReadOnly}
/>
<IdBadge id={surveyId} label={t("common.survey_id")} variant="column" />
@@ -3,12 +3,17 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { AuthorizationError } from "@formbricks/types/errors";
import { verifyAccountDeletionSsoReauthIntent } from "@/lib/jwt";
import { FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL } from "@/modules/account/constants";
import { deleteUserWithAccountDeletionAuthorization } from "@/modules/account/lib/account-deletion";
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
import { completeAccountDeletionSsoReauthenticationAndGetRedirectPath } from "./account-deletion-sso-complete";
import { queueAccountDeletionAuditEvent } from "@/modules/account/lib/account-deletion-audit";
import { completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath } from "./account-deletion-sso-complete";
vi.mock("server-only", () => ({}));
const mockConstants = vi.hoisted(() => ({
isFormbricksCloud: false,
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
@@ -21,7 +26,9 @@ vi.mock("@formbricks/logger", () => ({
}));
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
get IS_FORMBRICKS_CLOUD() {
return mockConstants.isFormbricksCloud;
},
WEBAPP_URL: "http://localhost:3000",
}));
@@ -37,15 +44,15 @@ vi.mock("@/modules/auth/lib/authOptions", () => ({
authOptions: {},
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEventBackground: vi.fn(),
vi.mock("@/modules/account/lib/account-deletion-audit", () => ({
queueAccountDeletionAuditEvent: vi.fn(),
}));
const mockGetServerSession = vi.mocked(getServerSession);
const mockLoggerError = vi.mocked(logger.error);
const mockVerifyAccountDeletionSsoReauthIntent = vi.mocked(verifyAccountDeletionSsoReauthIntent);
const mockDeleteUserWithAccountDeletionAuthorization = vi.mocked(deleteUserWithAccountDeletionAuthorization);
const mockQueueAuditEventBackground = vi.mocked(queueAuditEventBackground);
const mockQueueAccountDeletionAuditEvent = vi.mocked(queueAccountDeletionAuditEvent);
const intent = {
id: "intent-id",
@@ -57,9 +64,10 @@ const intent = {
userId: "user-id",
};
describe("completeAccountDeletionSsoReauthenticationAndGetRedirectPath", () => {
describe("completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath", () => {
beforeEach(() => {
vi.clearAllMocks();
mockConstants.isFormbricksCloud = false;
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockGetServerSession.mockResolvedValue({
@@ -71,22 +79,22 @@ describe("completeAccountDeletionSsoReauthenticationAndGetRedirectPath", () => {
mockDeleteUserWithAccountDeletionAuthorization.mockResolvedValue({
oldUser: { id: intent.userId } as any,
});
mockQueueAuditEventBackground.mockResolvedValue(undefined);
mockQueueAccountDeletionAuditEvent.mockResolvedValue(undefined);
});
test("returns login without deleting when the callback has no intent", async () => {
await expect(completeAccountDeletionSsoReauthenticationAndGetRedirectPath({})).resolves.toBe(
await expect(completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({})).resolves.toBe(
"/auth/login"
);
expect(mockVerifyAccountDeletionSsoReauthIntent).not.toHaveBeenCalled();
expect(mockDeleteUserWithAccountDeletionAuthorization).not.toHaveBeenCalled();
expect(mockQueueAuditEventBackground).not.toHaveBeenCalled();
expect(mockQueueAccountDeletionAuditEvent).not.toHaveBeenCalled();
});
test("deletes the account after a completed SSO reauthentication", async () => {
test("deletes the account after a completed SSO identity confirmation", async () => {
await expect(
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: "intent-token" })
completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({ intent: "intent-token" })
).resolves.toBe("/auth/login");
expect(mockDeleteUserWithAccountDeletionAuthorization).toHaveBeenCalledWith({
@@ -94,15 +102,24 @@ describe("completeAccountDeletionSsoReauthenticationAndGetRedirectPath", () => {
userEmail: intent.email,
userId: intent.userId,
});
expect(mockQueueAuditEventBackground).toHaveBeenCalledWith({
action: "deleted",
targetType: "user",
userId: intent.userId,
userType: "user",
targetId: intent.userId,
organizationId: "unknown",
oldObject: { id: intent.userId },
expect(mockQueueAccountDeletionAuditEvent).toHaveBeenCalledWith({
oldUser: { id: intent.userId },
status: "success",
targetUserId: intent.userId,
});
});
test("redirects to the account deletion survey after SSO identity confirmation on Formbricks Cloud", async () => {
mockConstants.isFormbricksCloud = true;
await expect(
completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({ intent: "intent-token" })
).resolves.toBe(FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL);
expect(mockDeleteUserWithAccountDeletionAuthorization).toHaveBeenCalledWith({
confirmationEmail: intent.email,
userEmail: intent.email,
userId: intent.userId,
});
});
@@ -115,27 +132,43 @@ describe("completeAccountDeletionSsoReauthenticationAndGetRedirectPath", () => {
} as any);
await expect(
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: "intent-token" })
).resolves.toBe("/environments/env-id/settings/profile");
completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({ intent: "intent-token" })
).resolves.toBe("/environments/env-id/settings/profile?accountDeletionError=sso_reauth_failed");
expect(mockDeleteUserWithAccountDeletionAuthorization).not.toHaveBeenCalled();
expect(mockLoggerError).toHaveBeenCalledWith(
{ error: expect.any(AuthorizationError) },
"Failed to complete account deletion after SSO reauth"
"Failed to complete account deletion after SSO identity confirmation"
);
});
test("keeps the post-deletion redirect if audit logging fails after deletion", async () => {
mockQueueAuditEventBackground.mockRejectedValue(new Error("audit unavailable"));
test("returns to the profile page with an error when deletion fails after SSO identity confirmation", async () => {
mockDeleteUserWithAccountDeletionAuthorization.mockRejectedValue(
new AuthorizationError("marker missing")
);
await expect(
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: "intent-token" })
completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({ intent: "intent-token" })
).resolves.toBe("/environments/env-id/settings/profile?accountDeletionError=sso_reauth_failed");
expect(mockDeleteUserWithAccountDeletionAuthorization).toHaveBeenCalled();
expect(mockQueueAccountDeletionAuditEvent).toHaveBeenCalledWith({
status: "failure",
targetUserId: intent.userId,
});
});
test("keeps the post-deletion redirect if audit logging fails after deletion", async () => {
mockQueueAccountDeletionAuditEvent.mockRejectedValue(new Error("audit unavailable"));
await expect(
completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({ intent: "intent-token" })
).resolves.toBe("/auth/login");
expect(mockDeleteUserWithAccountDeletionAuthorization).toHaveBeenCalled();
expect(mockLoggerError).toHaveBeenCalledWith(
{ error: expect.any(Error) },
"Failed to complete account deletion after SSO reauth"
"Failed to complete account deletion after SSO identity confirmation"
);
});
@@ -152,7 +185,7 @@ describe("completeAccountDeletionSsoReauthenticationAndGetRedirectPath", () => {
} as any);
await expect(
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: ["intent-token"] })
completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({ intent: ["intent-token"] })
).resolves.toBe("/auth/login");
expect(mockDeleteUserWithAccountDeletionAuthorization).not.toHaveBeenCalled();
@@ -5,11 +5,14 @@ import { AuthorizationError } from "@formbricks/types/errors";
import { IS_FORMBRICKS_CLOUD, WEBAPP_URL } from "@/lib/constants";
import { verifyAccountDeletionSsoReauthIntent } from "@/lib/jwt";
import { getValidatedCallbackUrl } from "@/lib/utils/url";
import { FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL } from "@/modules/account/constants";
import {
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE,
FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL,
} from "@/modules/account/constants";
import { deleteUserWithAccountDeletionAuthorization } from "@/modules/account/lib/account-deletion";
import { queueAccountDeletionAuditEvent } from "@/modules/account/lib/account-deletion-audit";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
type TAccountDeletionSsoCompleteSearchParams = {
intent?: string | string[];
@@ -23,7 +26,7 @@ const getIntentToken = (intent: string | string[] | undefined) => {
return intent;
};
const getSafeRedirectPath = (returnToUrl: string) => {
const getSafeFailureRedirectPath = (returnToUrl: string) => {
const validatedReturnToUrl = getValidatedCallbackUrl(returnToUrl, WEBAPP_URL);
if (!validatedReturnToUrl) {
@@ -31,17 +34,23 @@ const getSafeRedirectPath = (returnToUrl: string) => {
}
const parsedReturnToUrl = new URL(validatedReturnToUrl);
parsedReturnToUrl.searchParams.set(
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE
);
return `${parsedReturnToUrl.pathname}${parsedReturnToUrl.search}${parsedReturnToUrl.hash}`;
};
const getPostDeletionRedirectPath = () =>
IS_FORMBRICKS_CLOUD ? FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL : "/auth/login";
export const completeAccountDeletionSsoReauthenticationAndGetRedirectPath = async ({
export const completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath = async ({
intent,
}: TAccountDeletionSsoCompleteSearchParams): Promise<string> => {
const intentToken = getIntentToken(intent);
let deletionSucceeded = false;
let redirectPath = "/auth/login";
let targetUserId: string | null = null;
if (!intentToken) {
return redirectPath;
@@ -49,33 +58,30 @@ export const completeAccountDeletionSsoReauthenticationAndGetRedirectPath = asyn
try {
const verifiedIntent = verifyAccountDeletionSsoReauthIntent(intentToken);
redirectPath = getSafeRedirectPath(verifiedIntent.returnToUrl);
targetUserId = verifiedIntent.userId;
redirectPath = getSafeFailureRedirectPath(verifiedIntent.returnToUrl);
const session = await getServerSession(authOptions);
if (!session?.user?.id || !session.user.email || session.user.id !== verifiedIntent.userId) {
throw new AuthorizationError("Account deletion SSO reauthentication session mismatch");
throw new AuthorizationError("Account deletion SSO identity confirmation session mismatch");
}
logger.info({ userId: session.user.id }, "Completing account deletion after SSO reauth");
logger.info({ userId: session.user.id }, "Completing account deletion after SSO identity confirmation");
const { oldUser } = await deleteUserWithAccountDeletionAuthorization({
confirmationEmail: verifiedIntent.email,
userEmail: session.user.email,
userId: session.user.id,
});
deletionSucceeded = true;
redirectPath = getPostDeletionRedirectPath();
await queueAuditEventBackground({
action: "deleted",
targetType: "user",
userId: session.user.id,
userType: "user",
targetId: session.user.id,
organizationId: UNKNOWN_DATA,
oldObject: oldUser,
status: "success",
});
logger.info({ userId: session.user.id }, "Completed account deletion after SSO reauth");
await queueAccountDeletionAuditEvent({ oldUser, status: "success", targetUserId: session.user.id });
logger.info({ userId: session.user.id }, "Completed account deletion after SSO identity confirmation");
} catch (error) {
logger.error({ error }, "Failed to complete account deletion after SSO reauth");
if (targetUserId && !deletionSucceeded) {
await queueAccountDeletionAuditEvent({ status: "failure", targetUserId });
}
logger.error({ error }, "Failed to complete account deletion after SSO identity confirmation");
}
return redirectPath;
@@ -1,10 +0,0 @@
import { redirect } from "next/navigation";
import { completeAccountDeletionSsoReauthenticationAndGetRedirectPath } from "./lib/account-deletion-sso-complete";
export default async function AccountDeletionSsoReauthCompletePage({
searchParams,
}: {
searchParams: Promise<{ intent?: string | string[] }>;
}) {
redirect(await completeAccountDeletionSsoReauthenticationAndGetRedirectPath(await searchParams));
}
@@ -0,0 +1,20 @@
import { type NextRequest, NextResponse } from "next/server";
import { completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath } from "./lib/account-deletion-sso-complete";
const getIntentSearchParam = (request: NextRequest): string | string[] | undefined => {
const intentValues = request.nextUrl.searchParams.getAll("intent");
if (intentValues.length === 0) {
return undefined;
}
return intentValues.length === 1 ? intentValues[0] : intentValues;
};
export const GET = async (request: NextRequest) => {
const redirectPath = await completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({
intent: getIntentSearchParam(request),
});
return NextResponse.redirect(new URL(redirectPath, request.url));
};
@@ -12,7 +12,7 @@ import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { getBiggerUploadFileSizePermission } from "@/modules/ee/license-check/lib/utils";
import { getSignedUrlForUpload } from "@/modules/storage/service";
import { getErrorResponseFromStorageError } from "@/modules/storage/utils";
import { getErrorResponseFromStorageError, validateSurveyAllowsFileUpload } from "@/modules/storage/utils";
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse(
@@ -107,6 +107,23 @@ export const POST = withV1ApiWrapper({
};
}
const fileUploadPermission = validateSurveyAllowsFileUpload({
fileName,
blocks: survey.blocks,
questions: survey.questions,
});
if (!fileUploadPermission.ok) {
return {
response: responses.badRequestResponse(
fileUploadPermission.reason === "no_file_upload_question"
? "Survey does not allow file uploads"
: "File extension is not allowed for this survey",
undefined
),
};
}
const isBiggerFileUploadAllowed = await getBiggerUploadFileSizePermission(organization.id);
const maxFileUploadSize = isBiggerFileUploadAllowed
? MAX_FILE_UPLOAD_SIZES.big
+11
View File
@@ -1,5 +1,6 @@
import { describe, expect, test } from "vitest";
import {
noContentResponse,
problemBadRequest,
problemForbidden,
problemInternalError,
@@ -118,3 +119,13 @@ describe("successResponse", () => {
expect(res.headers.get("Cache-Control")).toBe("private, max-age=60");
});
});
describe("noContentResponse", () => {
test("returns 204 without a body", async () => {
const res = noContentResponse({ requestId: "req-empty" });
expect(res.status).toBe(204);
expect(res.headers.get("X-Request-Id")).toBe("req-empty");
expect(res.headers.get("Cache-Control")).toContain("no-store");
expect(await res.text()).toBe("");
});
});
+15
View File
@@ -171,3 +171,18 @@ export function successResponse<T>(
}
);
}
export function noContentResponse(options?: { requestId?: string; cache?: string }): Response {
const headers: Record<string, string> = {
"Cache-Control": options?.cache ?? CACHE_NO_STORE,
};
if (options?.requestId) {
headers["X-Request-Id"] = options.requestId;
}
return new Response(null, {
status: 204,
headers,
});
}
@@ -1,318 +0,0 @@
import { ApiKeyPermission } from "@prisma/client";
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { getSurvey } from "@/lib/survey/service";
import { deleteSurvey } from "@/modules/survey/lib/surveys";
import { DELETE } from "./route";
const { mockAuthenticateRequest } = vi.hoisted(() => ({
mockAuthenticateRequest: vi.fn(),
}));
const { mockQueueAuditEvent, mockBuildAuditLogBaseObject } = vi.hoisted(() => ({
mockQueueAuditEvent: vi.fn().mockImplementation(async () => undefined),
mockBuildAuditLogBaseObject: vi.fn((action: string, targetType: string, apiUrl: string) => ({
action,
targetType,
userId: "unknown",
targetId: "unknown",
organizationId: "unknown",
status: "failure",
oldObject: undefined,
newObject: undefined,
userType: "api",
apiUrl,
})),
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@/app/api/v1/auth", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/app/api/v1/auth")>();
return { ...actual, authenticateRequest: mockAuthenticateRequest };
});
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyRateLimit: vi.fn().mockResolvedValue(undefined),
applyIPRateLimit: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return { ...actual, AUDIT_LOG_ENABLED: false };
});
vi.mock("@/app/api/v3/lib/auth", () => ({
requireV3WorkspaceAccess: vi.fn(),
}));
vi.mock("@/lib/survey/service", () => ({
getSurvey: vi.fn(),
}));
vi.mock("@/modules/survey/lib/surveys", () => ({
deleteSurvey: vi.fn(),
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEvent: mockQueueAuditEvent,
}));
vi.mock("@/app/lib/api/with-api-logging", () => ({
buildAuditLogBaseObject: mockBuildAuditLogBaseObject,
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
warn: vi.fn(),
error: vi.fn(),
})),
},
}));
const getServerSession = vi.mocked((await import("next-auth")).getServerSession);
const queueAuditEvent = vi.mocked((await import("@/modules/ee/audit-logs/lib/handler")).queueAuditEvent);
const surveyId = "clxx1234567890123456789012";
const workspaceId = "clzz9876543210987654321098";
function createRequest(url: string, requestId?: string, extraHeaders?: Record<string, string>): NextRequest {
const headers: Record<string, string> = { ...extraHeaders };
if (requestId) {
headers["x-request-id"] = requestId;
}
return new NextRequest(url, {
method: "DELETE",
headers,
});
}
const apiKeyAuth = {
type: "apiKey" as const,
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: {
accessControl: { read: true, write: true },
},
workspacePermissions: [
{
workspaceId,
workspaceName: "W",
permission: ApiKeyPermission.write,
},
],
};
describe("DELETE /api/v3/surveys/[surveyId]", () => {
beforeEach(() => {
vi.resetAllMocks();
getServerSession.mockResolvedValue({
user: { id: "user_1", name: "User", email: "u@example.com" },
expires: "2026-01-01",
} as any);
mockAuthenticateRequest.mockResolvedValue(null);
vi.mocked(getSurvey).mockResolvedValue({
id: surveyId,
name: "Delete me",
workspaceId: workspaceId,
type: "link",
status: "draft",
createdAt: new Date("2026-04-15T10:00:00.000Z"),
updatedAt: new Date("2026-04-15T10:00:00.000Z"),
responseCount: 0,
creator: { name: "User" },
singleUse: null,
} as any);
vi.mocked(deleteSurvey).mockResolvedValue({
id: surveyId,
workspaceId,
type: "link",
segment: null,
triggers: [],
} as any);
vi.mocked(requireV3WorkspaceAccess).mockResolvedValue({
workspaceId,
organizationId: "org_1",
});
});
afterEach(() => {
vi.clearAllMocks();
});
test("returns 401 when no session and no API key", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue(null);
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(401);
expect(vi.mocked(getSurvey)).not.toHaveBeenCalled();
});
test("returns 200 with session auth and deletes the survey", async () => {
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-delete"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(200);
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ user: expect.any(Object) }),
workspaceId,
"readWrite",
"req-delete",
`/api/v3/surveys/${surveyId}`
);
expect(deleteSurvey).toHaveBeenCalledWith(surveyId);
expect(await res.json()).toEqual({
data: {
id: surveyId,
},
});
});
test("returns 200 with x-api-key when the key can delete in the survey workspace", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue(apiKeyAuth as any);
const res = await DELETE(
createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-api-key", {
"x-api-key": "fbk_test",
}),
{
params: Promise.resolve({ surveyId }),
} as never
);
expect(res.status).toBe(200);
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ apiKeyId: "key_1" }),
workspaceId,
"readWrite",
"req-api-key",
`/api/v3/surveys/${surveyId}`
);
});
test("returns 400 when surveyId is invalid", async () => {
const res = await DELETE(createRequest("http://localhost/api/v3/surveys/not-a-cuid"), {
params: Promise.resolve({ surveyId: "not-a-cuid" }),
} as never);
expect(res.status).toBe(400);
expect(vi.mocked(getSurvey)).not.toHaveBeenCalled();
});
test("returns 403 when the survey does not exist", async () => {
vi.mocked(getSurvey).mockResolvedValueOnce(null);
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(403);
expect(deleteSurvey).not.toHaveBeenCalled();
});
test("returns 403 when the user lacks readWrite workspace access", async () => {
vi.mocked(requireV3WorkspaceAccess).mockResolvedValueOnce(
new Response(
JSON.stringify({
title: "Forbidden",
status: 403,
detail: "You are not authorized to access this resource",
requestId: "req-forbidden",
}),
{ status: 403, headers: { "Content-Type": "application/problem+json" } }
)
);
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-forbidden"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(403);
expect(deleteSurvey).not.toHaveBeenCalled();
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: "unknown",
organizationId: "unknown",
userId: "user_1",
userType: "user",
status: "failure",
oldObject: undefined,
})
);
});
test("returns 500 when survey deletion fails", async () => {
vi.mocked(deleteSurvey).mockRejectedValueOnce(new DatabaseError("db down"));
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-db"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(500);
const body = await res.json();
expect(body.code).toBe("internal_server_error");
});
test("returns 403 when the survey is deleted after authorization succeeds", async () => {
vi.mocked(deleteSurvey).mockRejectedValueOnce(new ResourceNotFoundError("Survey", surveyId));
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-race"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(403);
const body = await res.json();
expect(body.code).toBe("forbidden");
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: surveyId,
organizationId: "org_1",
userId: "user_1",
userType: "user",
status: "failure",
oldObject: expect.objectContaining({
id: surveyId,
workspaceId: workspaceId,
}),
})
);
});
test("queues an audit log with target, actor, organization, and old object", async () => {
await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-audit"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: surveyId,
organizationId: "org_1",
userId: "user_1",
userType: "user",
status: "success",
oldObject: expect.objectContaining({
id: surveyId,
workspaceId: workspaceId,
}),
})
);
});
});
+152 -25
View File
@@ -3,41 +3,173 @@ import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { problemForbidden, problemInternalError, successResponse } from "@/app/api/v3/lib/response";
import {
noContentResponse,
problemBadRequest,
problemForbidden,
problemInternalError,
successResponse,
} from "@/app/api/v3/lib/response";
import {
V3SurveyLanguageError,
V3SurveyUnsupportedShapeError,
serializeV3SurveyResource,
} from "@/app/api/v3/surveys/serializers";
import { getSurvey } from "@/lib/survey/service";
import { deleteSurvey } from "@/modules/survey/lib/surveys";
import { parseV3SurveyLanguageQuery } from "../language";
const surveyParamsSchema = z.object({
surveyId: z.cuid2(),
});
const surveyQuerySchema = z
.object({
lang: z
.union([z.string(), z.array(z.string())])
.transform((value, ctx) => {
const parsedLanguageQuery = parseV3SurveyLanguageQuery(value);
if (!parsedLanguageQuery.ok) {
ctx.addIssue({
code: "custom",
message: parsedLanguageQuery.message,
});
return z.NEVER;
}
return parsedLanguageQuery.languages;
})
.optional(),
})
.strict();
async function getAuthorizedSurvey(params: {
surveyId: string;
authentication: Parameters<typeof requireV3WorkspaceAccess>[0];
access: "read" | "readWrite";
requestId: string;
instance: string;
}) {
const { surveyId, authentication, access, requestId, instance } = params;
const survey = await getSurvey(surveyId);
if (!survey) {
return {
survey: null,
authResult: null,
response: problemForbidden(requestId, "You are not authorized to access this resource", instance),
};
}
const authResult = await requireV3WorkspaceAccess(
authentication,
survey.workspaceId,
access,
requestId,
instance
);
if (authResult instanceof Response) {
return { survey: null, authResult: null, response: authResult };
}
return { survey, authResult, response: null };
}
export const GET = withV3ApiWrapper({
auth: "both",
schemas: {
params: surveyParamsSchema,
query: surveyQuerySchema,
},
handler: async ({ parsedInput, authentication, requestId, instance }) => {
const surveyId = parsedInput.params.surveyId;
const log = logger.withContext({ requestId, surveyId });
try {
const { survey, response } = await getAuthorizedSurvey({
surveyId,
authentication,
access: "read",
requestId,
instance,
});
if (response) {
log.warn({ statusCode: response.status }, "Survey not found or not accessible");
return response;
}
try {
return successResponse(serializeV3SurveyResource(survey, { lang: parsedInput.query.lang }), {
requestId,
cache: "private, no-store",
});
} catch (error) {
if (error instanceof V3SurveyLanguageError) {
log.warn({ statusCode: 400, lang: parsedInput.query.lang }, "Invalid survey language selector");
return problemBadRequest(requestId, error.message, {
instance,
invalid_params: [
{
name: "lang",
reason: error.message,
},
],
});
}
if (error instanceof V3SurveyUnsupportedShapeError) {
log.warn({ statusCode: 400 }, "Unsupported v3 survey shape");
return problemBadRequest(requestId, error.message, {
instance,
invalid_params: [
{
name: "survey",
reason: error.message,
},
],
});
}
throw error;
}
} catch (error) {
if (error instanceof DatabaseError) {
log.error({ error, statusCode: 500 }, "Database error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
log.error({ error, statusCode: 500 }, "V3 survey get unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
export const DELETE = withV3ApiWrapper({
auth: "both",
action: "deleted",
targetType: "survey",
schemas: {
params: z.object({
surveyId: z.cuid2(),
}),
params: surveyParamsSchema,
},
handler: async ({ parsedInput, authentication, requestId, instance, auditLog }) => {
const surveyId = parsedInput.params.surveyId;
const log = logger.withContext({ requestId, surveyId });
try {
const survey = await getSurvey(surveyId);
if (!survey) {
log.warn({ statusCode: 403 }, "Survey not found or not accessible");
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
const authResult = await requireV3WorkspaceAccess(
const { survey, authResult, response } = await getAuthorizedSurvey({
surveyId,
authentication,
survey.workspaceId,
"readWrite",
access: "readWrite",
requestId,
instance
);
instance,
});
if (authResult instanceof Response) {
return authResult;
if (response) {
log.warn({ statusCode: 403 }, "Survey not found or not accessible");
return response;
}
if (auditLog) {
@@ -46,14 +178,9 @@ export const DELETE = withV3ApiWrapper({
auditLog.oldObject = survey;
}
const deletedSurvey = await deleteSurvey(surveyId);
await deleteSurvey(surveyId);
return successResponse(
{
id: deletedSurvey.id,
},
{ requestId }
);
return noContentResponse({ requestId });
} catch (error) {
if (error instanceof ResourceNotFoundError) {
log.warn({ errorCode: error.name, statusCode: 403 }, "Survey not found or not accessible");
@@ -0,0 +1,106 @@
import { describe, expect, test } from "vitest";
import {
normalizeV3SurveyLanguageTag,
parseV3SurveyLanguageQuery,
resolveV3SurveyLanguageCode,
} from "./language";
const languages = [
{ code: "en-US", enabled: true },
{ code: "de-DE", enabled: true },
{ code: "fr-FR", enabled: false },
];
describe("normalizeV3SurveyLanguageTag", () => {
test.each([
["EN_us", "en-US"],
["en-us", "en-US"],
["de", "de"],
["zh_hans_cn", "zh-Hans-CN"],
])("normalizes %s to %s", (input, expected) => {
expect(normalizeV3SurveyLanguageTag(input)).toBe(expected);
});
test("returns null for invalid language tags", () => {
expect(normalizeV3SurveyLanguageTag("not a locale")).toBeNull();
});
});
describe("parseV3SurveyLanguageQuery", () => {
test("parses comma-separated language selectors", () => {
expect(parseV3SurveyLanguageQuery("de-DE, pt_PT, EN_us")).toEqual({
ok: true,
languages: ["de-DE", "pt-PT", "en-US"],
});
});
test("parses repeated language selectors", () => {
expect(parseV3SurveyLanguageQuery(["de-DE", "pt_PT,en_us"])).toEqual({
ok: true,
languages: ["de-DE", "pt-PT", "en-US"],
});
});
test("deduplicates language selectors case-insensitively", () => {
expect(parseV3SurveyLanguageQuery("de-DE,DE_de")).toEqual({
ok: true,
languages: ["de-DE"],
});
});
test("rejects empty language selectors", () => {
expect(parseV3SurveyLanguageQuery("de-DE,")).toEqual({
ok: false,
message: "Language selector must contain valid comma-separated locale codes",
});
});
test("rejects invalid language selectors", () => {
expect(parseV3SurveyLanguageQuery("not a locale")).toEqual({
ok: false,
message: "Language 'not a locale' is not a valid locale code",
});
});
});
describe("resolveV3SurveyLanguageCode", () => {
test("matches configured languages case-insensitively and normalizes underscores", () => {
expect(resolveV3SurveyLanguageCode("DE_de", languages)).toEqual({ ok: true, code: "de-DE" });
});
test("resolves language-only tags when exactly one configured language matches", () => {
expect(resolveV3SurveyLanguageCode("de", languages)).toEqual({ ok: true, code: "de-DE" });
});
test("resolves disabled configured languages for management reads", () => {
expect(resolveV3SurveyLanguageCode("fr", languages)).toEqual({ ok: true, code: "fr-FR" });
});
test("returns ambiguous when language-only tags match multiple configured languages", () => {
expect(
resolveV3SurveyLanguageCode("pt", [
{ code: "pt-BR", enabled: true },
{ code: "pt-PT", enabled: true },
])
).toEqual({
ok: false,
reason: "ambiguous",
message: "Language 'pt' is ambiguous for this survey; use one of pt-BR, pt-PT",
});
});
test("returns unknown for languages not configured on the survey", () => {
expect(resolveV3SurveyLanguageCode("es-ES", languages)).toEqual({
ok: false,
reason: "unknown",
message: "Language 'es-ES' is not configured for this survey",
});
});
test("resolves the implicit default language for surveys without configured languages", () => {
expect(resolveV3SurveyLanguageCode("en", [{ code: "en-US", enabled: true }])).toEqual({
ok: true,
code: "en-US",
});
});
});
+112
View File
@@ -0,0 +1,112 @@
type TV3SurveyLanguageInput = {
code: string;
enabled: boolean;
};
type TV3SurveyLanguageQueryInput = string | string[];
type TResolveV3SurveyLanguageCodeResult =
| { ok: true; code: string }
| { ok: false; reason: "invalid" | "unknown" | "ambiguous"; message: string };
type TParseV3SurveyLanguageQueryResult = { ok: true; languages: string[] } | { ok: false; message: string };
export function normalizeV3SurveyLanguageTag(value: string): string | null {
const normalizedSeparators = value.trim().replaceAll("_", "-");
try {
return Intl.getCanonicalLocales(normalizedSeparators)[0] ?? null;
} catch {
return null;
}
}
export function parseV3SurveyLanguageQuery(
value: TV3SurveyLanguageQueryInput
): TParseV3SurveyLanguageQueryResult {
const requestedLanguages = (Array.isArray(value) ? value : [value])
.flatMap((entry) => entry.split(","))
.map((entry) => entry.trim());
if (requestedLanguages.some((entry) => entry.length === 0)) {
return {
ok: false,
message: "Language selector must contain valid comma-separated locale codes",
};
}
const normalizedLanguages: string[] = [];
for (const language of requestedLanguages) {
const normalizedLanguage = normalizeV3SurveyLanguageTag(language);
if (!normalizedLanguage) {
return {
ok: false,
message: `Language '${language}' is not a valid locale code`,
};
}
if (!normalizedLanguages.some((entry) => entry.toLowerCase() === normalizedLanguage.toLowerCase())) {
normalizedLanguages.push(normalizedLanguage);
}
}
return { ok: true, languages: normalizedLanguages };
}
function getLanguageSubtag(languageTag: string): string {
return languageTag.split("-")[0]?.toLowerCase() ?? languageTag.toLowerCase();
}
export function resolveV3SurveyLanguageCode(
requestedLanguage: string,
languages: TV3SurveyLanguageInput[]
): TResolveV3SurveyLanguageCodeResult {
const normalizedRequestedLanguage = normalizeV3SurveyLanguageTag(requestedLanguage);
if (!normalizedRequestedLanguage) {
return {
ok: false,
reason: "invalid",
message: `Language '${requestedLanguage}' is not a valid locale code`,
};
}
const normalizedLanguages = languages.map((language) => ({
...language,
code: normalizeV3SurveyLanguageTag(language.code) ?? language.code,
}));
const exactMatch = normalizedLanguages.find(
(language) => language.code.toLowerCase() === normalizedRequestedLanguage.toLowerCase()
);
if (exactMatch) {
return { ok: true, code: exactMatch.code };
}
const requestedSubtag = getLanguageSubtag(normalizedRequestedLanguage);
const hasRegionOrScript = normalizedRequestedLanguage.includes("-");
const matchingLanguages = hasRegionOrScript
? []
: normalizedLanguages.filter((language) => getLanguageSubtag(language.code) === requestedSubtag);
if (matchingLanguages.length > 1) {
return {
ok: false,
reason: "ambiguous",
message: `Language '${normalizedRequestedLanguage}' is ambiguous for this survey; use one of ${matchingLanguages.map((language) => language.code).join(", ")}`,
};
}
const languageMatch = matchingLanguages[0];
if (languageMatch) {
return { ok: true, code: languageMatch.code };
}
return {
ok: false,
reason: "unknown",
message: `Language '${normalizedRequestedLanguage}' is not configured for this survey`,
};
}
@@ -0,0 +1,274 @@
import { describe, expect, test } from "vitest";
import type { TSurvey } from "@formbricks/types/surveys/types";
import { V3SurveyUnsupportedShapeError, serializeV3SurveyResource } from "./serializers";
const baseSurvey = {
id: "survey_1",
workspaceId: "workspace_1",
createdAt: new Date("2026-04-21T10:00:00.000Z"),
updatedAt: new Date("2026-04-21T11:00:00.000Z"),
name: "Product Feedback",
type: "link",
status: "draft",
metadata: { cx: "enterprise" },
languages: [
{
default: true,
enabled: true,
language: { id: "lang_1", code: "en-US", alias: "en", createdAt: new Date(), updatedAt: new Date() },
},
{
default: false,
enabled: true,
language: { id: "lang_2", code: "de-DE", alias: "de", createdAt: new Date(), updatedAt: new Date() },
},
{
default: false,
enabled: false,
language: { id: "lang_3", code: "fr-FR", alias: "fr", createdAt: new Date(), updatedAt: new Date() },
},
],
questions: [],
welcomeCard: {
enabled: true,
headline: { default: "Welcome", "de-DE": "Willkommen", "fr-FR": "Bienvenue" },
},
blocks: [
{
id: "block_1",
name: "Intro",
elements: [
{
id: "satisfaction",
type: "openText",
headline: { default: "What should we improve?", "de-DE": "Was sollen wir verbessern?" },
subheader: { default: "Tell us more" },
required: true,
},
],
},
],
endings: [],
hiddenFields: { enabled: false, fieldIds: [] },
variables: [],
} as unknown as TSurvey;
describe("serializeV3SurveyResource", () => {
test("returns canonical multilingual fields using real locale codes", () => {
const resource = serializeV3SurveyResource(baseSurvey);
expect(resource.defaultLanguage).toBe("en-US");
expect(resource).not.toHaveProperty("language");
expect(resource.languages).toEqual([
{ code: "en-US", default: true, enabled: true },
{ code: "de-DE", default: false, enabled: true },
{ code: "fr-FR", default: false, enabled: false },
]);
expect(resource).toMatchObject({
welcomeCard: {
headline: {
"en-US": "Welcome",
"de-DE": "Willkommen",
"fr-FR": "Bienvenue",
},
},
});
expect(resource).toMatchObject({
blocks: [
{
elements: [
{
headline: {
"en-US": "What should we improve?",
"de-DE": "Was sollen wir verbessern?",
},
},
],
},
],
});
});
test("does not expose the internal default pseudo-locale for surveys without configured languages", () => {
const survey = {
...baseSurvey,
languages: [],
welcomeCard: {
enabled: true,
headline: { default: "Welcome" },
},
blocks: [
{
id: "block_1",
name: "Intro",
elements: [
{
id: "satisfaction",
type: "openText",
headline: { default: "What should we improve?" },
required: true,
},
],
},
],
} as unknown as TSurvey;
const resource = serializeV3SurveyResource(survey);
expect(resource.defaultLanguage).toBe("en-US");
expect(resource.languages).toEqual([{ code: "en-US", default: true, enabled: true }]);
expect(resource).toMatchObject({
welcomeCard: { headline: { "en-US": "Welcome" } },
blocks: [
{
elements: [
{
headline: { "en-US": "What should we improve?" },
},
],
},
],
});
});
test("filters the implicit default language for surveys without configured languages", () => {
const survey = {
...baseSurvey,
languages: [],
welcomeCard: {
enabled: true,
headline: { default: "Welcome" },
},
} as unknown as TSurvey;
const resource = serializeV3SurveyResource(survey, { lang: ["en"] });
expect(resource).not.toHaveProperty("language");
expect(resource).toMatchObject({ welcomeCard: { headline: { "en-US": "Welcome" } } });
});
test("preserves stored locale variants when their keys use non-canonical casing or separators", () => {
const survey = {
...baseSurvey,
welcomeCard: {
enabled: true,
headline: { default: "Welcome", de_de: "Willkommen" },
},
} as unknown as TSurvey;
const resource = serializeV3SurveyResource(survey);
expect(resource).toMatchObject({
welcomeCard: {
headline: {
"en-US": "Welcome",
"de-DE": "Willkommen",
},
},
});
});
test("filters fields for case-insensitive underscore language selectors while preserving maps", () => {
const resource = serializeV3SurveyResource(baseSurvey, { lang: ["DE_de"] });
expect(resource).not.toHaveProperty("language");
expect(resource).toMatchObject({
welcomeCard: { headline: { "de-DE": "Willkommen" } },
blocks: [
{
elements: [
{
headline: { "de-DE": "Was sollen wir verbessern?" },
subheader: { "de-DE": "Tell us more" },
},
],
},
],
});
});
test("resolves language-only selectors against configured survey languages", () => {
const resource = serializeV3SurveyResource(baseSurvey, { lang: ["de"] });
expect(resource).toMatchObject({ welcomeCard: { headline: { "de-DE": "Willkommen" } } });
});
test("filters disabled configured languages for management reads", () => {
const resource = serializeV3SurveyResource(baseSurvey, { lang: ["fr"] });
expect(resource).toMatchObject({ welcomeCard: { headline: { "fr-FR": "Bienvenue" } } });
});
test("filters multiple requested languages while preserving maps", () => {
const resource = serializeV3SurveyResource(baseSurvey, { lang: ["en-US", "de"] });
expect(resource).not.toHaveProperty("language");
expect(resource).toMatchObject({
welcomeCard: {
headline: {
"en-US": "Welcome",
"de-DE": "Willkommen",
},
},
blocks: [
{
elements: [
{
headline: {
"en-US": "What should we improve?",
"de-DE": "Was sollen wir verbessern?",
},
},
],
},
],
});
});
test("rejects ambiguous language-only selectors", () => {
const survey = {
...baseSurvey,
languages: [
{
default: true,
enabled: true,
language: {
id: "lang_1",
code: "pt-BR",
alias: "br",
createdAt: new Date(),
updatedAt: new Date(),
},
},
{
default: false,
enabled: true,
language: {
id: "lang_2",
code: "pt-PT",
alias: "pt",
createdAt: new Date(),
updatedAt: new Date(),
},
},
],
} as unknown as TSurvey;
expect(() => serializeV3SurveyResource(survey, { lang: ["pt"] })).toThrow(
"Language 'pt' is ambiguous for this survey; use one of pt-BR, pt-PT"
);
});
test("rejects legacy question-based survey shapes instead of returning an incomplete block resource", () => {
const survey = {
...baseSurvey,
questions: [{ id: "legacy_question", type: "openText", headline: { default: "Legacy question" } }],
blocks: [],
} as unknown as TSurvey;
expect(() => serializeV3SurveyResource(survey)).toThrow(V3SurveyUnsupportedShapeError);
expect(() => serializeV3SurveyResource(survey)).toThrow(
"Legacy question-based surveys are not supported by the v3 survey management API"
);
});
});
+182 -3
View File
@@ -1,13 +1,192 @@
import type { TSurvey } from "@/modules/survey/list/types/surveys";
import type { TSurvey as TInternalSurvey } from "@formbricks/types/surveys/types";
import type { TSurvey as TSurveyListRecord } from "@/modules/survey/list/types/surveys";
import { normalizeV3SurveyLanguageTag, resolveV3SurveyLanguageCode } from "./language";
export type TV3SurveyListItem = Omit<TSurvey, "singleUse">;
export type TV3SurveyListItem = Omit<TSurveyListRecord, "singleUse">;
const DEFAULT_V3_SURVEY_LANGUAGE = "en-US";
type TV3SurveyLanguage = {
code: string;
default: boolean;
enabled: boolean;
};
type TSerializedValue =
| string
| number
| boolean
| null
| TSerializedValue[]
| { [key: string]: TSerializedValue };
export class V3SurveyLanguageError extends Error {
constructor(message: string) {
super(message);
this.name = "V3SurveyLanguageError";
}
}
export class V3SurveyUnsupportedShapeError extends Error {
constructor(message: string) {
super(message);
this.name = "V3SurveyUnsupportedShapeError";
}
}
/**
* Keep the v3 API contract isolated from internal persistence naming.
* Surveys are scoped by workspaceId.
*/
export function serializeV3SurveyListItem(survey: TSurvey): TV3SurveyListItem {
export function serializeV3SurveyListItem(survey: TSurveyListRecord): TV3SurveyListItem {
const { singleUse: _omitSingleUse, ...rest } = survey;
return rest;
}
function toIsoString(value: Date | string): string {
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
}
function getSurveyLanguages(survey: TInternalSurvey): TV3SurveyLanguage[] {
const languages = (survey.languages ?? []).map((surveyLanguage) => ({
code: normalizeV3SurveyLanguageTag(surveyLanguage.language.code) ?? surveyLanguage.language.code,
default: surveyLanguage.default,
enabled: surveyLanguage.enabled,
}));
if (languages.length === 0) {
return [{ code: DEFAULT_V3_SURVEY_LANGUAGE, default: true, enabled: true }];
}
return languages;
}
function getDefaultLanguage(survey: TInternalSurvey): string {
const defaultLanguageCode = survey.languages?.find((surveyLanguage) => surveyLanguage.default)?.language
.code;
return defaultLanguageCode
? (normalizeV3SurveyLanguageTag(defaultLanguageCode) ?? defaultLanguageCode)
: DEFAULT_V3_SURVEY_LANGUAGE;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isI18nString(value: unknown): value is Record<string, string> {
return (
isPlainObject(value) &&
typeof value.default === "string" &&
Object.values(value).every((entry) => typeof entry === "string")
);
}
function getI18nValueForLanguage(value: Record<string, string>, languageCode: string): string | undefined {
if (typeof value[languageCode] === "string") {
return value[languageCode];
}
const matchingKey = Object.keys(value).find(
(key) => normalizeV3SurveyLanguageTag(key)?.toLowerCase() === languageCode.toLowerCase()
);
return matchingKey ? value[matchingKey] : undefined;
}
function serializeCanonicalValue(
value: unknown,
defaultLanguage: string,
languageCodes: Set<string>,
options?: { fallbackMissingTranslations?: boolean }
): TSerializedValue {
if (isI18nString(value)) {
const result: Record<string, string> = {
[defaultLanguage]: value.default,
};
for (const languageCode of languageCodes) {
const translatedValue = getI18nValueForLanguage(value, languageCode);
if (languageCode !== defaultLanguage) {
if (translatedValue !== undefined) {
result[languageCode] = translatedValue;
} else if (options?.fallbackMissingTranslations) {
result[languageCode] = value.default;
}
}
}
if (!languageCodes.has(defaultLanguage)) {
delete result[defaultLanguage];
}
return result;
}
if (Array.isArray(value)) {
return value.map((entry) => serializeCanonicalValue(entry, defaultLanguage, languageCodes, options));
}
if (isPlainObject(value)) {
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [
key,
serializeCanonicalValue(entry, defaultLanguage, languageCodes, options),
])
);
}
return value as TSerializedValue;
}
function resolveRequestedLanguage(languages: TV3SurveyLanguage[], language: string): string {
const result = resolveV3SurveyLanguageCode(language, languages);
if (!result.ok) {
throw new V3SurveyLanguageError(result.message);
}
return result.code;
}
function resolveRequestedLanguages(languages: TV3SurveyLanguage[], requestedLanguages?: string[]): string[] {
if (!requestedLanguages) {
return [];
}
return requestedLanguages.map((language) => resolveRequestedLanguage(languages, language));
}
export function serializeV3SurveyResource(survey: TInternalSurvey, options?: { lang?: string[] }) {
if (Array.isArray(survey.questions) && survey.questions.length > 0) {
throw new V3SurveyUnsupportedShapeError(
"Legacy question-based surveys are not supported by the v3 survey management API"
);
}
const defaultLanguage = getDefaultLanguage(survey);
const languages = getSurveyLanguages(survey);
const configuredLanguageCodes = new Set(languages.map((language) => language.code));
const requestedLanguages = resolveRequestedLanguages(languages, options?.lang);
const languageCodes = requestedLanguages.length > 0 ? new Set(requestedLanguages) : configuredLanguageCodes;
const serializeValue = (value: unknown) =>
serializeCanonicalValue(value, defaultLanguage, languageCodes, {
fallbackMissingTranslations: requestedLanguages.length > 0,
});
return {
id: survey.id,
workspaceId: survey.workspaceId,
createdAt: toIsoString(survey.createdAt),
updatedAt: toIsoString(survey.updatedAt),
name: survey.name,
type: survey.type,
status: survey.status,
metadata: survey.metadata,
defaultLanguage,
languages,
welcomeCard: serializeValue(survey.welcomeCard),
blocks: serializeValue(survey.blocks),
endings: serializeValue(survey.endings),
hiddenFields: survey.hiddenFields,
variables: survey.variables,
};
}
+4 -4
View File
@@ -2587,7 +2587,6 @@ checksums:
workspace/settings/profile/email_confirmation_does_not_match: eee9d13af9ca8c1f21b46fee764605ac
workspace/settings/profile/enable_two_factor_authentication: 476d45754f584b25cc66ab00eccbefaa
workspace/settings/profile/enter_the_code_from_your_authenticator_app_below: 9bae7024a84c2be6e2725b187e2244f9
workspace/settings/profile/google_sso_account_deletion_requires_setup: b2b60bb8bd1297f8b78af44b461733f5
workspace/settings/profile/lost_access: 70292321ff8232218d2261b11c40bc0a
workspace/settings/profile/or_enter_the_following_code_manually: c209f319f38984d8718cd272a2a60b97
workspace/settings/profile/organizations_delete_message: 9ca1794c9a63c8d82462abcf7109d31f
@@ -2598,8 +2597,8 @@ checksums:
workspace/settings/profile/save_the_following_backup_codes_in_a_safe_place: a5b9d38083770375f2372f93ac9a7b2b
workspace/settings/profile/scan_the_qr_code_below_with_your_authenticator_app: 5a6b60928590ce3b6be1bdf1d34cd45e
workspace/settings/profile/security_description: e833adde4e3e26795e61a93619c6caec
workspace/settings/profile/sso_reauthentication_failed: 1b2f4047fcec5571c67ee3235ad70853
workspace/settings/profile/sso_reauthentication_may_be_required_for_deletion: f2e0c238a701bd504a9527113b4f22e4
workspace/settings/profile/sso_identity_confirmation_failed: 9d0fcabd5321c07af1caf627b0c68bdf
workspace/settings/profile/sso_identity_confirmation_may_be_required_for_deletion: a220681b82105f16803bb542853809f4
workspace/settings/profile/two_factor_authentication: 97a428a54e41d68810a12dbae075f371
workspace/settings/profile/two_factor_authentication_description: 1429e4eeaea193f15fb508875d4fb601
workspace/settings/profile/two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app: 308ba145b3dc485ff4f17387e977b1f9
@@ -2902,6 +2901,7 @@ checksums:
workspace/surveys/edit/hidden_field_used_in_recall: 15d959528c3e817dce95640173d5d6a8
workspace/surveys/edit/hidden_field_used_in_recall_ending_card: ea0d0b12ca1c9400690658cb1b537025
workspace/surveys/edit/hidden_field_used_in_recall_welcome: bb498b6ee69c6311a3977d454866b610
workspace/surveys/edit/hidden_fields_description: e9221cd00ae2944602c19ffbc82358a4
workspace/surveys/edit/hide_back_button: 91355864b3032c3f57689074e2173544
workspace/surveys/edit/hide_back_button_description: caaa30cf43c5611577933a1c9f44b9ee
workspace/surveys/edit/hide_block_settings: c24c3d3892c251792e297cdc036d2fde
@@ -3203,6 +3203,7 @@ checksums:
workspace/surveys/edit/variable_used_in_recall: 1979c231569117297d1a19972b349617
workspace/surveys/edit/variable_used_in_recall_ending_card: e6ab9a124985708dd77067c014b7c514
workspace/surveys/edit/variable_used_in_recall_welcome: 60b995389b488366d8f6f53df35b6d8d
workspace/surveys/edit/variables_description: 4a55faa279acc675228f54dccf63db6a
workspace/surveys/edit/verify_email_before_submission: c05d345dc35f2d33839e4cfd72d11eb2
workspace/surveys/edit/verify_email_before_submission_description: 434ab3ee6134367513b633a9d4f7d772
workspace/surveys/edit/visibility_and_recontact: c27cb4ff3a4262266902a335c3ad5d84
@@ -3446,7 +3447,6 @@ checksums:
workspace/surveys/summary/no_identified_impressions: c3bc42e6feb9010ced905ded51c5afc4
workspace/surveys/summary/no_responses_found: f10190cffdda4ca1bed479acbb89b13f
workspace/surveys/summary/nps_promoters_tooltip: dea6a683c0c36189e325656d5a7596b8
workspace/surveys/summary/open_response_details: 0e5de115b5e605f68ea857cf8ef5533a
workspace/surveys/summary/other_values_found: 48a74ee68c05f7fb162072b50c683b6a
workspace/surveys/summary/overall: 6c6d6533013d4739766af84b2871bca6
workspace/surveys/summary/promoters: 41fbb8d0439227661253a82fda39f521
+2 -2
View File
@@ -25,7 +25,8 @@ export const TERMS_URL = env.TERMS_URL;
export const IMPRINT_URL = env.IMPRINT_URL;
export const IMPRINT_ADDRESS = env.IMPRINT_ADDRESS;
export const DISABLE_ACCOUNT_DELETION_SSO_REAUTH = env.DISABLE_ACCOUNT_DELETION_SSO_REAUTH === "1";
export const DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION =
env.DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION === "1";
export const DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS = env.DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS === "1";
export const DEBUG_SHOW_RESET_LINK = !IS_PRODUCTION && env.DEBUG_SHOW_RESET_LINK === "1";
export const PASSWORD_RESET_DISABLED = env.PASSWORD_RESET_DISABLED === "1";
@@ -33,7 +34,6 @@ export const PASSWORD_RESET_TOKEN_LIFETIME_MINUTES = env.PASSWORD_RESET_TOKEN_LI
export const EMAIL_VERIFICATION_DISABLED = env.EMAIL_VERIFICATION_DISABLED === "1";
export const GOOGLE_OAUTH_ENABLED = !!(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET);
export const GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED = env.GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED === "1";
export const GITHUB_OAUTH_ENABLED = !!(env.GITHUB_ID && env.GITHUB_SECRET);
export const AZURE_OAUTH_ENABLED = !!(env.AZUREAD_CLIENT_ID && env.AZUREAD_CLIENT_SECRET);
export const OIDC_OAUTH_ENABLED = !!(env.OIDC_CLIENT_ID && env.OIDC_CLIENT_SECRET && env.OIDC_ISSUER);
+2 -4
View File
@@ -153,7 +153,7 @@ const parsedEnv = createEnv({
BREVO_API_KEY: z.string().optional(),
BREVO_LIST_ID: z.string().optional(),
DATABASE_URL: z.url(),
DISABLE_ACCOUNT_DELETION_SSO_REAUTH: z.enum(["1", "0"]).optional(),
DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION: z.enum(["1", "0"]).optional(),
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: z.enum(["1", "0"]).optional(),
DEBUG_SHOW_RESET_LINK: z.enum(["1", "0"]).optional(),
// DEBUG is a common ambient env var in CI/tooling, so we accept arbitrary strings here
@@ -173,7 +173,6 @@ const parsedEnv = createEnv({
ENVIRONMENT: z.enum(["production", "staging"]).prefault("production"),
GITHUB_ID: z.string().optional(),
GITHUB_SECRET: z.string().optional(),
GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED: z.enum(["1", "0"]).optional(),
GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(),
AI_GOOGLE_CLOUD_PROJECT: z.string().optional(),
@@ -315,7 +314,7 @@ const parsedEnv = createEnv({
BREVO_LIST_ID: process.env.BREVO_LIST_ID,
CRON_SECRET: process.env.CRON_SECRET,
DATABASE_URL: process.env.DATABASE_URL,
DISABLE_ACCOUNT_DELETION_SSO_REAUTH: process.env.DISABLE_ACCOUNT_DELETION_SSO_REAUTH,
DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION: process.env.DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION,
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: process.env.DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS,
DEBUG: process.env.DEBUG,
DEBUG_SHOW_RESET_LINK: process.env.DEBUG_SHOW_RESET_LINK,
@@ -333,7 +332,6 @@ const parsedEnv = createEnv({
ENVIRONMENT: process.env.ENVIRONMENT,
GITHUB_ID: process.env.GITHUB_ID,
GITHUB_SECRET: process.env.GITHUB_SECRET,
GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED: process.env.GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED,
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
AI_GOOGLE_CLOUD_PROJECT: process.env.AI_GOOGLE_CLOUD_PROJECT,
+7 -7
View File
@@ -1126,7 +1126,7 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
});
});
describe("account deletion SSO reauthentication intents", () => {
describe("account deletion SSO identity confirmation intents", () => {
const accountDeletionIntent = {
id: "intent-id",
userId: mockUser.id,
@@ -1137,7 +1137,7 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
returnToUrl: "http://localhost:3000/environments/env-1/settings/profile",
};
test("round-trips encrypted account deletion reauth intents", () => {
test("round-trips encrypted account deletion SSO identity confirmation intents", () => {
const token = createAccountDeletionSsoReauthIntent(accountDeletionIntent);
expect(verifyAccountDeletionSsoReauthIntent(token)).toEqual(accountDeletionIntent);
@@ -1154,14 +1154,14 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
);
});
test("creates account deletion reauth intents with a ten minute default expiry", () => {
test("creates account deletion SSO identity confirmation intents with a ten minute default expiry", () => {
const token = createAccountDeletionSsoReauthIntent(accountDeletionIntent);
const decoded = jwt.decode(token) as any;
expect(decoded.exp - decoded.iat).toBe(10 * 60);
});
test("rejects account deletion reauth intents with the wrong purpose", () => {
test("rejects account deletion SSO identity confirmation intents with the wrong purpose", () => {
const token = jwt.sign(
{
id: crypto.symmetricEncrypt(accountDeletionIntent.id, TEST_ENCRYPTION_KEY),
@@ -1183,7 +1183,7 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
);
});
test("rejects account deletion reauth intents missing required fields", () => {
test("rejects account deletion SSO identity confirmation intents missing required fields", () => {
const token = jwt.sign(
{
id: crypto.symmetricEncrypt(accountDeletionIntent.id, TEST_ENCRYPTION_KEY),
@@ -1204,7 +1204,7 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
);
});
test("rejects expired account deletion reauth intents", () => {
test("rejects expired account deletion SSO identity confirmation intents", () => {
const expiredToken = jwt.sign(
{
id: crypto.symmetricEncrypt(accountDeletionIntent.id, TEST_ENCRYPTION_KEY),
@@ -1225,7 +1225,7 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
expect(() => verifyAccountDeletionSsoReauthIntent(expiredToken)).toThrow();
});
test("throws when account deletion reauth intent secrets are missing", async () => {
test("throws when account deletion SSO identity confirmation intent secrets are missing", async () => {
await testMissingSecretsError(createAccountDeletionSsoReauthIntent, [accountDeletionIntent]);
const token = jwt.sign(
-37
View File
@@ -216,43 +216,6 @@ export const getResponse = reactCache(async (responseId: string): Promise<TRespo
}
});
export const getResponseWithQuotas = reactCache(
async (responseId: string): Promise<TResponseWithQuotas | null> => {
validateInputs([responseId, ZId]);
try {
const responsePrisma = await prisma.response.findUnique({
where: {
id: responseId,
},
select: {
...responseSelection,
quotaLinks: {
where: { status: "screenedIn" },
include: { quota: { select: { id: true, name: true } } },
},
},
});
if (!responsePrisma) {
return null;
}
const { quotaLinks, ...rest } = responsePrisma;
return {
...mapResponsePrismaToResponse(rest),
quotas: quotaLinks.map((ql) => ql.quota),
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
}
);
export const getResponseSnapshotForPipeline = async (responseId: string): Promise<TResponse | null> => {
validateInputs([responseId, ZId]);
@@ -31,7 +31,6 @@ import {
getResponseBySingleUseId,
getResponseCountBySurveyId,
getResponseDownloadFile,
getResponseWithQuotas,
getResponsesByWorkspaceId,
responseSelection,
updateResponse,
@@ -171,70 +170,6 @@ describe("Tests for getResponse service", () => {
});
});
describe("Tests for getResponseWithQuotas service", () => {
describe("Happy Path", () => {
test("Returns the response with screened-in quotas", async () => {
prisma.response.findUnique.mockResolvedValue(mockResponseWithQuotas);
const result = await getResponseWithQuotas(mockResponseWithQuotas.id);
expect(result).toEqual({
...expectedResponseWithoutPerson,
quotas: mockResponseWithQuotas.quotaLinks.map(
(ql: { quota: { id: string; name: string } }) => ql.quota
),
});
});
test("Returns an empty quotas array when no quotaLinks are screened in", async () => {
prisma.response.findUnique.mockResolvedValue({ ...mockResponse, quotaLinks: [] } as any);
const result = await getResponseWithQuotas(mockResponse.id);
expect(result).toEqual({ ...expectedResponseWithoutPerson, quotas: [] });
});
test("Selects only screened-in quotaLinks", async () => {
prisma.response.findUnique.mockResolvedValue({ ...mockResponse, quotaLinks: [] } as any);
await getResponseWithQuotas(mockResponse.id);
const findUniqueCall = prisma.response.findUnique.mock.calls.at(-1)?.[0];
expect(findUniqueCall?.select?.quotaLinks).toEqual({
where: { status: "screenedIn" },
include: { quota: { select: { id: true, name: true } } },
});
});
});
describe("Sad Path", () => {
testInputValidation(getResponseWithQuotas, "123#");
test("Returns null when no response is found", async () => {
prisma.response.findUnique.mockResolvedValue(null);
const result = await getResponseWithQuotas(mockResponse.id);
expect(result).toBeNull();
});
test("Throws DatabaseError on PrismaClientKnownRequestError", async () => {
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
});
prisma.response.findUnique.mockRejectedValue(errToThrow);
await expect(getResponseWithQuotas(mockResponse.id)).rejects.toThrow(DatabaseError);
});
test("Rethrows generic errors", async () => {
prisma.response.findUnique.mockRejectedValue(new Error("boom"));
await expect(getResponseWithQuotas(mockResponse.id)).rejects.toThrow("boom");
});
});
});
describe("Tests for getSurveySummary service", () => {
describe("Happy Path", () => {
test("Returns a summary of the survey responses", async () => {
@@ -5,7 +5,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
AuthenticationError,
AuthorizationError,
ConfigurationError,
EXPECTED_ERROR_NAMES,
INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE,
InvalidInputError,
@@ -75,7 +74,6 @@ describe("isExpectedError (shared helper)", () => {
"ValidationError",
"AuthenticationError",
"OperationNotAllowedError",
"ConfigurationError",
"QueryExecutionError",
"TooManyRequestsError",
"InvalidPasswordResetTokenError",
@@ -96,7 +94,6 @@ describe("isExpectedError (shared helper)", () => {
{ ErrorClass: InvalidInputError, args: ["Invalid input"] },
{ ErrorClass: ValidationError, args: ["Invalid data"] },
{ ErrorClass: OperationNotAllowedError, args: ["Not allowed"] },
{ ErrorClass: ConfigurationError, args: ["Cube is not configured"] },
{ ErrorClass: QueryExecutionError, args: ["Cube query failed. Details: connect ECONNREFUSED"] },
{ ErrorClass: InvalidPasswordResetTokenError, args: [INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE] },
{ ErrorClass: UniqueConstraintError, args: ["Already exists"] },
@@ -188,12 +185,6 @@ describe("actionClient handleServerError", () => {
expect(Sentry.captureException).not.toHaveBeenCalled();
});
test("ConfigurationError returns its message and is not sent to Sentry", async () => {
const result = await executeThrowingAction(new ConfigurationError("Cube is not configured"));
expect(result?.serverError).toBe("Cube is not configured");
expect(Sentry.captureException).not.toHaveBeenCalled();
});
test("QueryExecutionError returns its message and is not sent to Sentry", async () => {
const result = await executeThrowingAction(
new QueryExecutionError("Cube query failed. Details: connect ECONNREFUSED")
+4 -4
View File
@@ -2700,7 +2700,6 @@
"email_confirmation_does_not_match": "E-Mail-Bestätigung stimmt nicht überein.",
"enable_two_factor_authentication": "Zwei-Faktor-Authentifizierung aktivieren",
"enter_the_code_from_your_authenticator_app_below": "Gib unten den Code aus deiner Authentifizierungs-App ein.",
"google_sso_account_deletion_requires_setup": "Wir konnten deine Identität nicht mit deinem SSO-Anbieter bestätigen. Bitte versuche es erneut oder kontaktiere deinen Administrator.",
"lost_access": "Zugang verloren",
"or_enter_the_following_code_manually": "Oder gib folgenden Code manuell ein:",
"organizations_delete_message": "Du bist der einzige Inhaber dieser Organisationen, daher <b>werden sie ebenfalls gelöscht.</b>",
@@ -2711,8 +2710,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Speichere die folgenden Backup-Codes an einem sicheren Ort.",
"scan_the_qr_code_below_with_your_authenticator_app": "Scanne den QR-Code unten mit deiner Authenticator-App.",
"security_description": "Verwalte dein Passwort und andere Sicherheitseinstellungen wie die Zwei-Faktor-Authentifizierung (2FA).",
"sso_reauthentication_failed": "SSO-Neuauthentifizierung fehlgeschlagen. Bitte versuche erneut, dein Konto zu löschen.",
"sso_reauthentication_may_be_required_for_deletion": "Bei SSO-Konten kann die Auswahl von Löschen zu einer Weiterleitung zu deinem Identitätsanbieter führen. Wenn deine Identität bestätigt wird, wird dein Konto automatisch gelöscht.",
"sso_identity_confirmation_failed": "SSO-Identitätsbestätigung fehlgeschlagen. Bitte versuche erneut, dein Konto zu löschen.",
"sso_identity_confirmation_may_be_required_for_deletion": "Bei SSO-Konten kann dich die Auswahl von Löschen zu deinem Identitätsanbieter weiterleiten, um dieses Konto zu bestätigen. Wenn dasselbe Konto bestätigt wird, wird die Löschung automatisch fortgesetzt.",
"two_factor_authentication": "Zwei-Faktor-Authentifizierung",
"two_factor_authentication_description": "Füge deinem Konto eine zusätzliche Sicherheitsebene hinzu, falls dein Passwort gestohlen wird.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Zwei-Faktor-Authentifizierung aktiviert. Bitte gib den sechsstelligen Code aus deiner Authenticator-App ein.",
@@ -3021,6 +3020,7 @@
"hidden_field_used_in_recall": "Verborgenes Feld \"{hiddenField}\" wird in Frage {questionIndex} abgerufen.",
"hidden_field_used_in_recall_ending_card": "Verborgenes Feld \"{hiddenField}\" wird in der Abschlusskarte abgerufen",
"hidden_field_used_in_recall_welcome": "Verborgenes Feld \"{hiddenField}\" wird in der Willkommenskarte abgerufen.",
"hidden_fields_description": "Übergib versteckte Daten an deine Umfrage, ohne sie den Teilnehmern zu zeigen.",
"hide_back_button": "\"Zurück\"-Button ausblenden",
"hide_back_button_description": "Zeige den Zurück-Button in der Umfrage nicht an",
"hide_block_settings": "Block-Einstellungen ausblenden",
@@ -3326,6 +3326,7 @@
"variable_used_in_recall": "Die Variable \"{variable}\" wird in Frage {questionIndex} abgerufen.",
"variable_used_in_recall_ending_card": "Die Variable {variable} wird in der Abschlusskarte abgerufen",
"variable_used_in_recall_welcome": "Die Variable \"{variable}\" wird in der Willkommenskarte abgerufen.",
"variables_description": "Definiere und berechne Werte während deiner Umfrage.",
"verify_email_before_submission": "E-Mail vor dem Absenden verifizieren",
"verify_email_before_submission_description": "Lass nur Personen mit einer echten E-Mail-Adresse antworten.",
"visibility_and_recontact": "Sichtbarkeit & erneute Kontaktaufnahme",
@@ -3597,7 +3598,6 @@
"no_identified_impressions": "Keine Impressionen von identifizierten Kontakten",
"no_responses_found": "Keine Antworten gefunden",
"nps_promoters_tooltip": "{percentage}% der Befragten haben eine Bewertung von 9 oder 10 gegeben (NPS-Promotoren).",
"open_response_details": "Details zu offenen Antworten",
"other_values_found": "Andere Werte gefunden",
"overall": "Gesamt",
"promoters": "Promotoren",
+4 -4
View File
@@ -2700,7 +2700,6 @@
"email_confirmation_does_not_match": "Email confirmation does not match.",
"enable_two_factor_authentication": "Enable two factor authentication",
"enter_the_code_from_your_authenticator_app_below": "Enter the code from your authenticator app below.",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "Lost access",
"or_enter_the_following_code_manually": "Or enter the following code manually:",
"organizations_delete_message": "You are the only owner of these organizations, so they <b>will be deleted as well.</b>",
@@ -2711,8 +2710,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Save the following backup codes in a safe place.",
"scan_the_qr_code_below_with_your_authenticator_app": "Scan the QR code below with your authenticator app.",
"security_description": "Manage your password and other security settings like two-factor authentication (2FA).",
"sso_reauthentication_failed": "SSO reauthentication failed. Please try deleting your account again.",
"sso_reauthentication_may_be_required_for_deletion": "For SSO accounts, selecting Delete may redirect you to your identity provider. If your identity is confirmed, your account will be deleted automatically.",
"sso_identity_confirmation_failed": "SSO identity confirmation failed. Please try deleting your account again.",
"sso_identity_confirmation_may_be_required_for_deletion": "For SSO accounts, selecting Delete may redirect you to your identity provider to confirm this account. If the same account is confirmed, deletion continues automatically.",
"two_factor_authentication": "Two factor authentication",
"two_factor_authentication_description": "Add an extra layer of security to your account in case your password is stolen.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Two-factor authentication enabled. Please enter the six-digit code from your authenticator app.",
@@ -3021,6 +3020,7 @@
"hidden_field_used_in_recall": "Hidden field “{hiddenField}” is being recalled in question {questionIndex}.",
"hidden_field_used_in_recall_ending_card": "Hidden field “{hiddenField}” is being recalled in Ending Card",
"hidden_field_used_in_recall_welcome": "Hidden field “{hiddenField}” is being recalled in Welcome card.",
"hidden_fields_description": "Pass hidden data into your survey without showing it to respondents.",
"hide_back_button": "Hide “Back” button",
"hide_back_button_description": "Do not display the back button in the survey",
"hide_block_settings": "Hide Block settings",
@@ -3326,6 +3326,7 @@
"variable_used_in_recall": "Variable “{variable}” is being recalled in question {questionIndex}.",
"variable_used_in_recall_ending_card": "Variable {variable} is being recalled in Ending Card",
"variable_used_in_recall_welcome": "Variable “{variable}” is being recalled in Welcome Card.",
"variables_description": "Define and compute values throughout your survey.",
"verify_email_before_submission": "Verify email before submission",
"verify_email_before_submission_description": "Only let people with a real email respond.",
"visibility_and_recontact": "Visibility & Recontact",
@@ -3597,7 +3598,6 @@
"no_identified_impressions": "No impressions from identified contacts",
"no_responses_found": "No responses found",
"nps_promoters_tooltip": "{percentage}% of respondents gave a rating of 9 or 10 (NPS promoters).",
"open_response_details": "Open response details",
"other_values_found": "Other values found",
"overall": "Overall",
"promoters": "Promoters",
+4 -4
View File
@@ -2700,7 +2700,6 @@
"email_confirmation_does_not_match": "Email confirmation does not match.",
"enable_two_factor_authentication": "Activar autenticación de dos factores",
"enter_the_code_from_your_authenticator_app_below": "Introduce el código de tu aplicación de autenticación a continuación.",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "Acceso perdido",
"or_enter_the_following_code_manually": "O introduce el siguiente código manualmente:",
"organizations_delete_message": "Eres el único propietario de estas organizaciones, por lo que <b>también serán eliminadas.</b>",
@@ -2711,8 +2710,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Guarda los siguientes códigos de respaldo en un lugar seguro.",
"scan_the_qr_code_below_with_your_authenticator_app": "Escanea el código QR a continuación con tu aplicación de autenticación.",
"security_description": "Gestiona tu contraseña y otros ajustes de seguridad como la autenticación de dos factores (2FA).",
"sso_reauthentication_failed": "La reautenticación SSO falló. Intenta eliminar tu cuenta de nuevo.",
"sso_reauthentication_may_be_required_for_deletion": "En las cuentas SSO, al seleccionar Eliminar es posible que se te redirija a tu proveedor de identidad. Si se confirma tu identidad, tu cuenta se eliminará automáticamente.",
"sso_identity_confirmation_failed": "No se pudo confirmar la identidad mediante SSO. Intenta eliminar tu cuenta de nuevo.",
"sso_identity_confirmation_may_be_required_for_deletion": "En las cuentas SSO, al seleccionar Eliminar es posible que se te redirija a tu proveedor de identidad para confirmar esta cuenta. Si se confirma la misma cuenta, la eliminación continuará automáticamente.",
"two_factor_authentication": "Autenticación de dos factores",
"two_factor_authentication_description": "Añade una capa adicional de seguridad a tu cuenta en caso de que tu contraseña sea robada.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Autenticación de dos factores activada. Por favor, introduce el código de seis dígitos de tu aplicación de autenticación.",
@@ -3021,6 +3020,7 @@
"hidden_field_used_in_recall": "El campo oculto \"{hiddenField}\" se está recordando en la pregunta {questionIndex}.",
"hidden_field_used_in_recall_ending_card": "El campo oculto \"{hiddenField}\" se está recordando en la tarjeta final.",
"hidden_field_used_in_recall_welcome": "El campo oculto \"{hiddenField}\" se está recordando en la tarjeta de bienvenida.",
"hidden_fields_description": "Pasa datos ocultos a tu encuesta sin mostrárselos a los encuestados.",
"hide_back_button": "Ocultar botón 'Atrás'",
"hide_back_button_description": "No mostrar el botón de retroceso en la encuesta",
"hide_block_settings": "Ocultar ajustes del bloque",
@@ -3326,6 +3326,7 @@
"variable_used_in_recall": "La variable \"{variable}\" se está recuperando en la pregunta {questionIndex}.",
"variable_used_in_recall_ending_card": "La variable {variable} se está recuperando en la tarjeta final",
"variable_used_in_recall_welcome": "La variable \"{variable}\" se está recuperando en la tarjeta de bienvenida.",
"variables_description": "Define y calcula valores a lo largo de tu encuesta.",
"verify_email_before_submission": "Verificar correo electrónico antes del envío",
"verify_email_before_submission_description": "Solo permite responder a personas con un correo electrónico real.",
"visibility_and_recontact": "Visibilidad y recontacto",
@@ -3597,7 +3598,6 @@
"no_identified_impressions": "No hay impresiones de contactos identificados",
"no_responses_found": "No se han encontrado respuestas",
"nps_promoters_tooltip": "El {percentage}% de los encuestados dieron una puntuación de 9 o 10 (promotores NPS).",
"open_response_details": "Detalles de respuesta abierta",
"other_values_found": "Otros valores encontrados",
"overall": "General",
"promoters": "Promotores",
+4 -4
View File
@@ -2700,7 +2700,6 @@
"email_confirmation_does_not_match": "Email confirmation does not match.",
"enable_two_factor_authentication": "Activer l'authentification à deux facteurs",
"enter_the_code_from_your_authenticator_app_below": "Entrez le code de votre application d'authentification ci-dessous.",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "Accès perdu",
"or_enter_the_following_code_manually": "Ou entrez le code suivant manuellement :",
"organizations_delete_message": "Tu es le seul propriétaire de ces organisations, elles <b>seront aussi supprimées.</b>",
@@ -2711,8 +2710,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Enregistrez les codes de sauvegarde suivants dans un endroit sûr.",
"scan_the_qr_code_below_with_your_authenticator_app": "Scannez le code QR ci-dessous avec votre application d'authentification.",
"security_description": "Gérez votre mot de passe et d'autres paramètres de sécurité comme l'authentification à deux facteurs (2FA).",
"sso_reauthentication_failed": "La réauthentification SSO a échoué. Veuillez réessayer de supprimer votre compte.",
"sso_reauthentication_may_be_required_for_deletion": "Pour les comptes SSO, sélectionner Supprimer peut vous rediriger vers votre fournisseur d'identité. Si votre identité est confirmée, votre compte sera supprimé automatiquement.",
"sso_identity_confirmation_failed": "La confirmation d'identité SSO a échoué. Veuillez réessayer de supprimer votre compte.",
"sso_identity_confirmation_may_be_required_for_deletion": "Pour les comptes SSO, sélectionner Supprimer peut vous rediriger vers votre fournisseur d'identité afin de confirmer ce compte. Si le même compte est confirmé, la suppression se poursuit automatiquement.",
"two_factor_authentication": "Authentification à deux facteurs",
"two_factor_authentication_description": "Ajoutez une couche de sécurité supplémentaire à votre compte au cas où votre mot de passe serait volé.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Authentification à deux facteurs activée. Veuillez entrer le code à six chiffres de votre application d'authentification.",
@@ -3021,6 +3020,7 @@
"hidden_field_used_in_recall": "Le champ caché \"{hiddenField}\" est rappelé dans la question {questionIndex}.",
"hidden_field_used_in_recall_ending_card": "Le champ caché \"{hiddenField}\" est rappelé dans la carte de fin.",
"hidden_field_used_in_recall_welcome": "Le champ caché \"{hiddenField}\" est rappelé dans la carte de bienvenue.",
"hidden_fields_description": "Transmets des données masquées dans ton questionnaire sans les montrer aux répondants.",
"hide_back_button": "Masquer le bouton 'Retour'",
"hide_back_button_description": "Ne pas afficher le bouton retour dans l'enquête",
"hide_block_settings": "Masquer les paramètres du bloc",
@@ -3326,6 +3326,7 @@
"variable_used_in_recall": "La variable \"{variable}\" est rappelée dans la question {questionIndex}.",
"variable_used_in_recall_ending_card": "La variable {variable} est rappelée dans la carte de fin.",
"variable_used_in_recall_welcome": "La variable \"{variable}\" est rappelée dans la carte de bienvenue.",
"variables_description": "Définis et calcule des valeurs tout au long de ton questionnaire.",
"verify_email_before_submission": "Vérifiez l'email avant la soumission",
"verify_email_before_submission_description": "Ne laissez répondre que les personnes ayant une véritable adresse e-mail.",
"visibility_and_recontact": "Visibilité et recontact",
@@ -3597,7 +3598,6 @@
"no_identified_impressions": "Aucune impression des contacts identifiés",
"no_responses_found": "Aucune réponse trouvée",
"nps_promoters_tooltip": "{percentage} % des répondants ont donné une note de 9 ou 10 (promoteurs NPS).",
"open_response_details": "Détails des réponses ouvertes",
"other_values_found": "D'autres valeurs trouvées",
"overall": "Globalement",
"promoters": "Promoteurs",
+4 -4
View File
@@ -2700,7 +2700,6 @@
"email_confirmation_does_not_match": "Az e-mail-cím megerősítése nem egyezik.",
"enable_two_factor_authentication": "Kétfaktoros hitelesítés engedélyezése",
"enter_the_code_from_your_authenticator_app_below": "Adja meg a hitelesítő alkalmazásból származó kódot lent.",
"google_sso_account_deletion_requires_setup": "Nem tudtuk megerősíteni a személyazonosságát az SSO-szolgáltatóval. Próbálja meg újra, vagy vegye fel a kapcsolatot az adminisztrátorral.",
"lost_access": "Elvesztett hozzáférés",
"or_enter_the_following_code_manually": "Vagy adja meg a következő kódot kézileg:",
"organizations_delete_message": "Ön az egyetlen tulajdonosa ezeknek a szervezeteknek, ezért <b>azok is törölve lesznek.</b>",
@@ -2711,8 +2710,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Mentse el a következő visszaszerzési kódokat egy biztonságos helyre.",
"scan_the_qr_code_below_with_your_authenticator_app": "Olvassa be a lenti QR-kódot a hitelesítő alkalmazásával.",
"security_description": "A jelszava és egyéb biztonsági beállítások, például a kétfaktoros hitelesítés (2FA) kezelése.",
"sso_reauthentication_failed": "Az SSO újrahitelesítése nem sikerült. Próbálja meg újra törölni a fiókt.",
"sso_reauthentication_may_be_required_for_deletion": "SSO-fiókoknál a Törlés kiválasztása átirányíthatja Önt a személyazonosság-szolgáltatóhoz. Ha a személyazonossága megerősítésre került, akkor a fiókja automatikusan törölve lesz.",
"sso_identity_confirmation_failed": "Az SSO-identitás megerősítése nem sikerült. Kérjük, próbáld meg újra törölni a fiókodat.",
"sso_identity_confirmation_may_be_required_for_deletion": "SSO-fiókok esetén a Törlés kiválasztása átirányíthat az identitásszolgáltatóhoz a fiók megerősítéséhez. Ha ugyanazt a fiókot erősítik meg, a törlés automatikusan folytatódik.",
"two_factor_authentication": "Kétfaktoros hitelesítés",
"two_factor_authentication_description": "További biztonsági réteg hozzáadása a fiókjához arra az esetre, ha a jelszavát ellopnák.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "A kétfaktoros hitelesítés engedélyezve van. Adja a meg a 6 számjegyű kódot a hitelesítő alkalmazásából.",
@@ -3021,6 +3020,7 @@
"hidden_field_used_in_recall": "A(z) „{hiddenField}” rejtett mező visszahívásra kerül a(z) {questionIndex}. kérdésben.",
"hidden_field_used_in_recall_ending_card": "A(z) „{hiddenField}” rejtett mező visszahívásra kerül a befejező kártyában",
"hidden_field_used_in_recall_welcome": "A(z) „{hiddenField}” rejtett mező visszahívásra kerül az üdvözlő kártyában.",
"hidden_fields_description": "Rejtett adatokat továbbíthat a felmérésbe anélkül, hogy azokat a válaszadók látnák.",
"hide_back_button": "A „Vissza” gomb elrejtése",
"hide_back_button_description": "Ne jelenjen meg a vissza gomb a kérdőívben",
"hide_block_settings": "Blokkbeállítások elrejtése",
@@ -3326,6 +3326,7 @@
"variable_used_in_recall": "A(z) „{variable}” változó visszahívásra kerül a(z) {questionIndex}. kérdésben.",
"variable_used_in_recall_ending_card": "A(z) {variable} változó visszahívásra kerül a befejező kártyában",
"variable_used_in_recall_welcome": "A(z) „{variable}” változó visszahívásra kerül az üdvözlő kártyában.",
"variables_description": "Értékeket határozhat meg és számíthat ki a felmérés során.",
"verify_email_before_submission": "E-mail-cím ellenőrzése a beküldés előtt",
"verify_email_before_submission_description": "Csak valódi e-mail-címmel rendelkező személyek válaszolhassanak.",
"visibility_and_recontact": "Láthatóság és újbóli kapcsolatfelvétel",
@@ -3597,7 +3598,6 @@
"no_identified_impressions": "Nincsenek azonosított partnerektől származó megtekintések",
"no_responses_found": "Nem találhatók válaszok",
"nps_promoters_tooltip": "A válaszadók {percentage}%-a 9-es vagy 10-es értékelést adott (NPS promoters).",
"open_response_details": "Nyitott válasz részletei",
"other_values_found": "Más értékek találhatók",
"overall": "Összesen",
"promoters": "Népszerűsítők",
+4 -4
View File
@@ -2700,7 +2700,6 @@
"email_confirmation_does_not_match": "Email confirmation does not match.",
"enable_two_factor_authentication": "二段階認証を有効にする",
"enter_the_code_from_your_authenticator_app_below": "認証アプリからコードを以下に入力してください。",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "アクセスを紛失しましたか",
"or_enter_the_following_code_manually": "または、以下のコードを手動で入力してください:",
"organizations_delete_message": "あなたはこれらの組織の唯一のオーナーであるため、組織も<b>削除されます。</b>",
@@ -2711,8 +2710,8 @@
"save_the_following_backup_codes_in_a_safe_place": "以下のバックアップコードを安全な場所に保存してください。",
"scan_the_qr_code_below_with_your_authenticator_app": "以下のQRコードを認証アプリでスキャンしてください。",
"security_description": "パスワードや二段階認証(2FA)などの他のセキュリティ設定を管理します。",
"sso_reauthentication_failed": "SSO の再認証に失敗しました。もう一度アカウントの削除を試しください。",
"sso_reauthentication_may_be_required_for_deletion": "SSOアカウントの場合、削除を選択するとIDプロバイダーリダイレクトされることがあります。本人確認が完了すると、アカウントは自動的に削除されます。",
"sso_identity_confirmation_failed": "SSOでの本人確認に失敗しました。もう一度アカウントの削除を試しください。",
"sso_identity_confirmation_may_be_required_for_deletion": "SSOアカウントの場合、削除を選択すると、このアカウントを確認するためにIDプロバイダーリダイレクトされることがあります。同じアカウントが確認されると、削除は自動的に続行されます。",
"two_factor_authentication": "二段階認証",
"two_factor_authentication_description": "パスワードが盗まれた場合に備えて、アカウントにセキュリティの追加レイヤーを追加します。",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "二段階認証が有効になりました。認証アプリから6桁のコードを入力してください。",
@@ -3021,6 +3020,7 @@
"hidden_field_used_in_recall": "隠し フィールド \"{hiddenField}\" が 質問 {questionIndex} で 呼び出され て います 。",
"hidden_field_used_in_recall_ending_card": "隠し フィールド \"{hiddenField}\" が エンディング カード で 呼び出され て います。",
"hidden_field_used_in_recall_welcome": "隠し フィールド \"{hiddenField}\" が ウェルカム カード で 呼び出され て います。",
"hidden_fields_description": "回答者に表示せずに、非表示データをアンケートに渡すことができます。",
"hide_back_button": "「戻る」ボタンを非表示",
"hide_back_button_description": "フォームに「戻る」ボタンを表示しない",
"hide_block_settings": "ブロック設定を非表示",
@@ -3326,6 +3326,7 @@
"variable_used_in_recall": "変数 \"{variable}\" が 質問 {questionIndex} で 呼び出され て います 。",
"variable_used_in_recall_ending_card": "変数 {variable} が エンディング カード で 呼び出され て います。",
"variable_used_in_recall_welcome": "変数 \"{variable}\" が ウェルカム カード で 呼び出され て います。",
"variables_description": "アンケート全体で値を定義し、計算できます。",
"verify_email_before_submission": "送信前にメールアドレスを認証",
"verify_email_before_submission_description": "有効なメールアドレスを持つ人のみが回答できるようにする",
"visibility_and_recontact": "表示と再接触",
@@ -3597,7 +3598,6 @@
"no_identified_impressions": "識別済みコンタクトからのインプレッションはありません",
"no_responses_found": "回答が見つかりません",
"nps_promoters_tooltip": "回答者の{percentage}%が9または10の評価をしました(NPSプロモーター)。",
"open_response_details": "自由回答の詳細",
"other_values_found": "他の値が見つかりました",
"overall": "全体",
"promoters": "推奨者",
+4 -4
View File
@@ -2700,7 +2700,6 @@
"email_confirmation_does_not_match": "Email confirmation does not match.",
"enable_two_factor_authentication": "Schakel tweefactorauthenticatie in",
"enter_the_code_from_your_authenticator_app_below": "Voer hieronder de code uit uw authenticator-app in.",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "Toegang verloren",
"or_enter_the_following_code_manually": "Of voer de volgende code handmatig in:",
"organizations_delete_message": "U bent de enige eigenaar van deze organisaties, dus <b>worden ze ook verwijderd.</b>",
@@ -2711,8 +2710,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Bewaar de volgende back-upcodes op een veilige plaats.",
"scan_the_qr_code_below_with_your_authenticator_app": "Scan onderstaande QR-code met uw authenticator-app.",
"security_description": "Beheer uw wachtwoord en andere beveiligingsinstellingen zoals tweefactorauthenticatie (2FA).",
"sso_reauthentication_failed": "SSO-herauthenticatie is mislukt. Probeer je account opnieuw te verwijderen.",
"sso_reauthentication_may_be_required_for_deletion": "Bij SSO-accounts kan het selecteren van Verwijderen u doorsturen naar uw identiteitsprovider. Als uw identiteit is bevestigd, wordt uw account automatisch verwijderd.",
"sso_identity_confirmation_failed": "SSO-identiteitsbevestiging is mislukt. Probeer je account opnieuw te verwijderen.",
"sso_identity_confirmation_may_be_required_for_deletion": "Voor SSO-accounts kan het selecteren van Verwijderen je doorsturen naar je identiteitsprovider om dit account te bevestigen. Als hetzelfde account wordt bevestigd, gaat de verwijdering automatisch verder.",
"two_factor_authentication": "Tweefactorauthenticatie",
"two_factor_authentication_description": "Voeg een extra beveiligingslaag toe aan uw account voor het geval uw wachtwoord wordt gestolen.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Tweefactorauthenticatie ingeschakeld. Voer de zescijferige code van uw authenticator-app in.",
@@ -3021,6 +3020,7 @@
"hidden_field_used_in_recall": "Verborgen veld \"{hiddenField}\" wordt opgeroepen in vraag {questionIndex}.",
"hidden_field_used_in_recall_ending_card": "Verborgen veld \"{hiddenField}\" wordt opgeroepen in de eindkaart",
"hidden_field_used_in_recall_welcome": "Verborgen veld \"{hiddenField}\" wordt opgeroepen in de welkomstkaart.",
"hidden_fields_description": "Geef verborgen gegevens door aan je enquête zonder dat respondenten het zien.",
"hide_back_button": "Knop 'Terug' verbergen",
"hide_back_button_description": "Geef de terugknop niet weer in de enquête",
"hide_block_settings": "Blokinstellingen verbergen",
@@ -3326,6 +3326,7 @@
"variable_used_in_recall": "Variabele \"{variable}\" wordt opgeroepen in vraag {questionIndex}.",
"variable_used_in_recall_ending_card": "Variabele {variable} wordt opgeroepen in de eindkaart",
"variable_used_in_recall_welcome": "Variabele \"{variable}\" wordt opgeroepen in de welkomstkaart.",
"variables_description": "Definieer en bereken waarden tijdens je enquête.",
"verify_email_before_submission": "Verifieer uw e-mailadres voordat u het verzendt",
"verify_email_before_submission_description": "Laat alleen mensen met een echte e-mail reageren.",
"visibility_and_recontact": "Zichtbaarheid & opnieuw contact",
@@ -3597,7 +3598,6 @@
"no_identified_impressions": "Geen weergaven van geïdentificeerde contacten",
"no_responses_found": "Geen reacties gevonden",
"nps_promoters_tooltip": "{percentage}% van de respondenten gaf een beoordeling van 9 of 10 (NPS promoters).",
"open_response_details": "Details open antwoorden",
"other_values_found": "Andere waarden gevonden",
"overall": "Algemeen",
"promoters": "Promoters",
+4 -4
View File
@@ -2700,7 +2700,6 @@
"email_confirmation_does_not_match": "Email confirmation does not match.",
"enable_two_factor_authentication": "Ativar autenticação de dois fatores",
"enter_the_code_from_your_authenticator_app_below": "Digite o código do seu app autenticador abaixo.",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "Perdi o acesso",
"or_enter_the_following_code_manually": "Ou insira o seguinte código manualmente:",
"organizations_delete_message": "Você é o único dono dessas organizações, então elas <b>também serão apagadas.</b>",
@@ -2711,8 +2710,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Guarde os seguintes códigos de backup em um lugar seguro.",
"scan_the_qr_code_below_with_your_authenticator_app": "Escaneie o código QR abaixo com seu app autenticador.",
"security_description": "Gerencie sua senha e outras configurações de segurança como a autenticação de dois fatores (2FA).",
"sso_reauthentication_failed": "A reautenticação SSO falhou. Tente excluir sua conta novamente.",
"sso_reauthentication_may_be_required_for_deletion": "Para contas SSO, selecionar Excluir pode redirecionar você para seu provedor de identidade. Se sua identidade for confirmada, sua conta será excluída automaticamente.",
"sso_identity_confirmation_failed": "A confirmação de identidade via SSO falhou. Tente excluir sua conta novamente.",
"sso_identity_confirmation_may_be_required_for_deletion": "Para contas SSO, selecionar Excluir pode redirecionar você para o provedor de identidade para confirmar esta conta. Se a mesma conta for confirmada, a exclusão continuará automaticamente.",
"two_factor_authentication": "Autenticação de dois fatores",
"two_factor_authentication_description": "Adicione uma camada extra de segurança à sua conta caso sua senha seja roubada.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Autenticação de dois fatores ativada. Por favor, insira o código de seis dígitos do seu app autenticador.",
@@ -3021,6 +3020,7 @@
"hidden_field_used_in_recall": "Campo oculto \"{hiddenField}\" está sendo recordado na pergunta {questionIndex}.",
"hidden_field_used_in_recall_ending_card": "Campo oculto \"{hiddenField}\" está sendo recordado no card de Encerramento.",
"hidden_field_used_in_recall_welcome": "Campo oculto \"{hiddenField}\" está sendo recordado no card de Boas-Vindas.",
"hidden_fields_description": "Passe dados ocultos para sua pesquisa sem mostrá-los aos respondentes.",
"hide_back_button": "Ocultar botão 'Voltar'",
"hide_back_button_description": "Não exibir o botão de voltar na pesquisa",
"hide_block_settings": "Ocultar configurações do bloco",
@@ -3326,6 +3326,7 @@
"variable_used_in_recall": "Variável \"{variable}\" está sendo recordada na pergunta {questionIndex}.",
"variable_used_in_recall_ending_card": "Variável {variable} está sendo recordada no card de Encerramento",
"variable_used_in_recall_welcome": "Variável \"{variable}\" está sendo recordada no Card de Boas-Vindas.",
"variables_description": "Defina e calcule valores ao longo da sua pesquisa.",
"verify_email_before_submission": "Verifique o e-mail antes de enviar",
"verify_email_before_submission_description": "Deixe só quem tem um email real responder.",
"visibility_and_recontact": "Visibilidade e recontato",
@@ -3597,7 +3598,6 @@
"no_identified_impressions": "Nenhuma impressão de contatos identificados",
"no_responses_found": "Nenhuma resposta encontrada",
"nps_promoters_tooltip": "{percentage}% dos entrevistados deram uma nota de 9 ou 10 (promotores NPS).",
"open_response_details": "Detalhes das respostas abertas",
"other_values_found": "Outros valores encontrados",
"overall": "No geral",
"promoters": "Promotores",
+4 -4
View File
@@ -2700,7 +2700,6 @@
"email_confirmation_does_not_match": "Email confirmation does not match.",
"enable_two_factor_authentication": "Ativar autenticação de dois fatores",
"enter_the_code_from_your_authenticator_app_below": "Introduza o código da sua aplicação de autenticação abaixo.",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "Perdeu o acesso",
"or_enter_the_following_code_manually": "Ou insira o seguinte código manualmente:",
"organizations_delete_message": "É o único proprietário destas organizações, por isso <b>também serão eliminadas.</b>",
@@ -2711,8 +2710,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Guarde os seguintes códigos de backup num local seguro.",
"scan_the_qr_code_below_with_your_authenticator_app": "Digitalize o código QR abaixo com a sua aplicação de autenticação.",
"security_description": "Gerir a sua palavra-passe e outras definições de segurança, como a autenticação de dois fatores (2FA).",
"sso_reauthentication_failed": "A reautenticação SSO falhou. Tente eliminar a sua conta novamente.",
"sso_reauthentication_may_be_required_for_deletion": "Para contas SSO, selecionar Eliminar pode redirecioná-lo para o seu fornecedor de identidade. Se a sua identidade for confirmada, a sua conta será eliminada automaticamente.",
"sso_identity_confirmation_failed": "A confirmação de identidade por SSO falhou. Tenta eliminar a tua conta novamente.",
"sso_identity_confirmation_may_be_required_for_deletion": "Para contas SSO, selecionar Eliminar pode redirecionar-te para o teu fornecedor de identidade para confirmares esta conta. Se a mesma conta for confirmada, a eliminação continuará automaticamente.",
"two_factor_authentication": "Autenticação de dois fatores",
"two_factor_authentication_description": "Adicione uma camada extra de segurança à sua conta caso a sua palavra-passe seja roubada.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Autenticação de dois fatores ativada. Introduza o código de seis dígitos da sua aplicação de autenticação.",
@@ -3021,6 +3020,7 @@
"hidden_field_used_in_recall": "Campo oculto \"{hiddenField}\" está a ser recordado na pergunta {questionIndex}.",
"hidden_field_used_in_recall_ending_card": "Campo oculto \"{hiddenField}\" está a ser recordado no Cartão de Conclusão",
"hidden_field_used_in_recall_welcome": "Campo oculto \"{hiddenField}\" está a ser recordado no cartão de boas-vindas.",
"hidden_fields_description": "Passa dados ocultos para o teu inquérito sem os mostrar aos inquiridos.",
"hide_back_button": "Ocultar botão 'Retroceder'",
"hide_back_button_description": "Não mostrar o botão de retroceder no inquérito",
"hide_block_settings": "Ocultar definições do bloco",
@@ -3326,6 +3326,7 @@
"variable_used_in_recall": "Variável \"{variable}\" está a ser recordada na pergunta {questionIndex}.",
"variable_used_in_recall_ending_card": "Variável {variable} está a ser recordada no Cartão de Conclusão",
"variable_used_in_recall_welcome": "Variável \"{variable}\" está a ser recordada no cartão de boas-vindas.",
"variables_description": "Define e calcula valores ao longo do teu inquérito.",
"verify_email_before_submission": "Verificar email antes da submissão",
"verify_email_before_submission_description": "Permitir apenas que pessoas com um email real respondam.",
"visibility_and_recontact": "Visibilidade e Recontacto",
@@ -3597,7 +3598,6 @@
"no_identified_impressions": "Sem impressões de contactos identificados",
"no_responses_found": "Nenhuma resposta encontrada",
"nps_promoters_tooltip": "{percentage}% dos inquiridos deram uma classificação de 9 ou 10 (promotores NPS).",
"open_response_details": "Detalhes de respostas abertas",
"other_values_found": "Outros valores encontrados",
"overall": "Geral",
"promoters": "Promotores",
+4 -4
View File
@@ -2700,7 +2700,6 @@
"email_confirmation_does_not_match": "Email confirmation does not match.",
"enable_two_factor_authentication": "Activează autentificarea în doi pași",
"enter_the_code_from_your_authenticator_app_below": "Introduceți codul din aplicația dvs. de autentificare mai jos.",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "Acces pierdut",
"or_enter_the_following_code_manually": "Sau introduceți manual următorul cod:",
"organizations_delete_message": "Ești singurul proprietar al acestor organizații, deci ele <b>vor fi șterse și ele.</b>",
@@ -2711,8 +2710,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Salvează următoarele coduri de rezervă într-un loc sigur.",
"scan_the_qr_code_below_with_your_authenticator_app": "Scanați codul QR de mai jos cu aplicația dvs. de autentificare.",
"security_description": "Gestionează parola și alte setări de securitate, precum autentificarea în doi pași (2FA).",
"sso_reauthentication_failed": "Reautentificarea SSO a eșuat. Te rugăm să încerci din nou să îți ștergi contul.",
"sso_reauthentication_may_be_required_for_deletion": "Pentru conturile SSO, selectarea opțiunii Șterge te poate redirecționa către furnizorul tău de identitate. Dacă identitatea ta este confirmată, contul va fi șters automat.",
"sso_identity_confirmation_failed": "Confirmarea identității SSO a eșuat. Te rugăm să încerci din nou să îți ștergi contul.",
"sso_identity_confirmation_may_be_required_for_deletion": "Pentru conturile SSO, selectarea opțiunii Șterge te poate redirecționa către furnizorul de identitate pentru a confirma acest cont. Dacă același cont este confirmat, ștergerea continuă automat.",
"two_factor_authentication": "Autentificare în doi pași",
"two_factor_authentication_description": "Adăugați un strat suplimentar de securitate la contul dvs. în cazul în care parola este furată.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Autentificare în doi pași activată. Introduceți codul de șase cifre din aplicația dvs. de autentificare.",
@@ -3021,6 +3020,7 @@
"hidden_field_used_in_recall": "Câmpul ascuns \"{hiddenField}\" este reamintit în întrebarea {questionIndex}.",
"hidden_field_used_in_recall_ending_card": "Câmpul ascuns \"{hiddenField}\" este reamintit în Cardul de Încheiere.",
"hidden_field_used_in_recall_welcome": "Câmpul ascuns \"{hiddenField}\" este reamintit în cardul de bun venit.",
"hidden_fields_description": "Transmite date ascunse în sondajul tău fără a le afișa respondenților.",
"hide_back_button": "Ascunde butonul 'Înapoi'",
"hide_back_button_description": "Nu afișa butonul Înapoi în sondaj",
"hide_block_settings": "Ascunde setările blocului",
@@ -3326,6 +3326,7 @@
"variable_used_in_recall": "Variabila \"{variable}\" este reamintită în întrebarea {questionIndex}.",
"variable_used_in_recall_ending_card": "Variabila {variable} este reamintită în Cardul de Încheiere.",
"variable_used_in_recall_welcome": "Variabila \"{variable}\" este reamintită în cardul de bun venit.",
"variables_description": "Definește și calculează valori pe parcursul sondajului tău.",
"verify_email_before_submission": "Verifică emailul înainte de trimitere",
"verify_email_before_submission_description": "Permite doar persoanelor cu un email real să răspundă.",
"visibility_and_recontact": "Vizibilitate și recontactare",
@@ -3597,7 +3598,6 @@
"no_identified_impressions": "Nicio impresie de la contactele identificate",
"no_responses_found": "Nu s-au găsit răspunsuri",
"nps_promoters_tooltip": "{percentage}% dintre respondenți au acordat o evaluare de 9 sau 10 (promotori NPS).",
"open_response_details": "Detalii răspunsuri deschise",
"other_values_found": "Alte valori găsite",
"overall": "General",
"promoters": "Promotori",
+4 -4
View File
@@ -2700,7 +2700,6 @@
"email_confirmation_does_not_match": "Email confirmation does not match.",
"enable_two_factor_authentication": "Включить двухфакторную аутентификацию",
"enter_the_code_from_your_authenticator_app_below": "Введите ниже код из вашего приложения-аутентификатора.",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "Потерян доступ",
"or_enter_the_following_code_manually": "Или введите следующий код вручную:",
"organizations_delete_message": "Вы являетесь единственным владельцем этих организаций, поэтому они <b>также будут удалены.</b>",
@@ -2711,8 +2710,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Сохраните следующие резервные коды в безопасном месте.",
"scan_the_qr_code_below_with_your_authenticator_app": "Отсканируйте QR-код ниже с помощью вашего приложения-аутентификатора.",
"security_description": "Управляйте паролем и другими настройками безопасности, такими как двухфакторная аутентификация (2FA).",
"sso_reauthentication_failed": "Повторная аутентификация SSO не удалась. Попробуйте удалить аккаунт еще раз.",
"sso_reauthentication_may_be_required_for_deletion": "Для учетных записей SSO выбор Удалить может перенаправить вас к поставщику удостоверений. Если ваша личность будет подтверждена, учетная запись будет удалена автоматически.",
"sso_identity_confirmation_failed": "Не удалось подтвердить личность через SSO. Попробуйте удалить аккаунт ещё раз.",
"sso_identity_confirmation_may_be_required_for_deletion": "Для аккаунтов SSO при выборе «Удалить» вы можете быть перенаправлены к поставщику удостоверений, чтобы подтвердить этот аккаунт. Если будет подтверждён тот же аккаунт, удаление продолжится автоматически.",
"two_factor_authentication": "Двухфакторная аутентификация",
"two_factor_authentication_description": "Добавьте дополнительный уровень защиты вашему аккаунту на случай, если ваш пароль будет украден.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Двухфакторная аутентификация включена. Пожалуйста, введите шестизначный код из вашего приложения-аутентификатора.",
@@ -3021,6 +3020,7 @@
"hidden_field_used_in_recall": "Скрытое поле «{hiddenField}» используется в вопросе {questionIndex}.",
"hidden_field_used_in_recall_ending_card": "Скрытое поле «{hiddenField}» используется в финальной карточке",
"hidden_field_used_in_recall_welcome": "Скрытое поле «{hiddenField}» используется в приветственной карточке.",
"hidden_fields_description": "Передавайте скрытые данные в опрос, не показывая их респондентам.",
"hide_back_button": "Скрыть кнопку «Назад»",
"hide_back_button_description": "Не отображать кнопку «Назад» в опросе",
"hide_block_settings": "Скрыть настройки блока",
@@ -3326,6 +3326,7 @@
"variable_used_in_recall": "Переменная «{variable}» используется в вопросе {questionIndex}.",
"variable_used_in_recall_ending_card": "Переменная {variable} используется в финальной карточке",
"variable_used_in_recall_welcome": "Переменная «{variable}» используется в приветственной карточке.",
"variables_description": "Определяйте и вычисляйте значения на протяжении всего опроса.",
"verify_email_before_submission": "Проверять email перед отправкой",
"verify_email_before_submission_description": "Разрешить отвечать только пользователям с реальным email.",
"visibility_and_recontact": "Видимость и повторный контакт",
@@ -3597,7 +3598,6 @@
"no_identified_impressions": "Нет показов от идентифицированных контактов",
"no_responses_found": "Ответы не найдены",
"nps_promoters_tooltip": "{percentage}% респондентов дали оценку 9 или 10 (промоутеры NPS).",
"open_response_details": "Детали открытых ответов",
"other_values_found": "Найдены другие значения",
"overall": "В целом",
"promoters": "Сторонники",
+4 -4
View File
@@ -2700,7 +2700,6 @@
"email_confirmation_does_not_match": "Email confirmation does not match.",
"enable_two_factor_authentication": "Aktivera tvåfaktorsautentisering",
"enter_the_code_from_your_authenticator_app_below": "Ange koden från din autentiseringsapp nedan.",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "Förlorad åtkomst",
"or_enter_the_following_code_manually": "Eller ange följande kod manuellt:",
"organizations_delete_message": "Du är den enda ägaren av dessa organisationer, så de <b>kommer också att tas bort.</b>",
@@ -2711,8 +2710,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Spara följande reservkoder på ett säkert ställe.",
"scan_the_qr_code_below_with_your_authenticator_app": "Skanna QR-koden nedan med din autentiseringsapp.",
"security_description": "Hantera ditt lösenord och andra säkerhetsinställningar som tvåfaktorsautentisering (2FA).",
"sso_reauthentication_failed": "SSO-återautentisering misslyckades. Försök ta bort ditt konto igen.",
"sso_reauthentication_may_be_required_for_deletion": "För SSO-konton kan valet Ta bort omdirigera dig till din identitetsleverantör. Om din identitet bekräftas tas ditt konto bort automatiskt.",
"sso_identity_confirmation_failed": "SSO-identitetsbekräftelsen misslyckades. Försök ta bort ditt konto igen.",
"sso_identity_confirmation_may_be_required_for_deletion": "För SSO-konton kan valet Ta bort omdirigera dig till din identitetsleverantör för att bekräfta kontot. Om samma konto bekräftas fortsätter borttagningen automatiskt.",
"two_factor_authentication": "Tvåfaktorsautentisering",
"two_factor_authentication_description": "Lägg till ett extra säkerhetslager till ditt konto om ditt lösenord blir stulet.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Tvåfaktorsautentisering aktiverad. Vänligen ange den sexsiffriga koden från din autentiseringsapp.",
@@ -3021,6 +3020,7 @@
"hidden_field_used_in_recall": "Dolt fält \"{hiddenField}\" återkallas i fråga {questionIndex}.",
"hidden_field_used_in_recall_ending_card": "Dolt fält \"{hiddenField}\" återkallas i avslutningskortet",
"hidden_field_used_in_recall_welcome": "Dolt fält \"{hiddenField}\" återkallas i välkomstkortet.",
"hidden_fields_description": "Skicka dold data till din enkät utan att visa den för respondenter.",
"hide_back_button": "Dölj 'Tillbaka'-knapp",
"hide_back_button_description": "Visa inte tillbakaknappen i enkäten",
"hide_block_settings": "Dölj blockinställningar",
@@ -3326,6 +3326,7 @@
"variable_used_in_recall": "Variabel \"{variable}\" återkallas i fråga {questionIndex}.",
"variable_used_in_recall_ending_card": "Variabel {variable} återkallas i avslutningskortet",
"variable_used_in_recall_welcome": "Variabel \"{variable}\" återkallas i välkomstkortet.",
"variables_description": "Definiera och beräkna värden genom hela din enkät.",
"verify_email_before_submission": "Verifiera e-post före inskickning",
"verify_email_before_submission_description": "Låt endast personer med en riktig e-post svara.",
"visibility_and_recontact": "Synlighet och återkontakt",
@@ -3597,7 +3598,6 @@
"no_identified_impressions": "Inga visningar från identifierade kontakter",
"no_responses_found": "Inga svar hittades",
"nps_promoters_tooltip": "{percentage}% av respondenterna gav ett betyg på 9 eller 10 (NPS-ambassadörer).",
"open_response_details": "Detaljer för öppna svar",
"other_values_found": "Andra värden hittades",
"overall": "Övergripande",
"promoters": "Ambassadörer",
+4 -4
View File
@@ -2700,7 +2700,6 @@
"email_confirmation_does_not_match": "E-posta onayı eşleşmiyor.",
"enable_two_factor_authentication": "İki faktörlü kimlik doğrulamayı etkinleştir",
"enter_the_code_from_your_authenticator_app_below": "Kimlik doğrulayıcı uygulamandaki kodu aşağıya gir.",
"google_sso_account_deletion_requires_setup": "SSO sağlayıcınızla kimliğinizi doğrulayamadık. Lütfen tekrar deneyin veya yöneticinizle iletişime geçin.",
"lost_access": "Erişimi kaybettim",
"or_enter_the_following_code_manually": "Ya da aşağıdaki kodu manuel olarak gir:",
"organizations_delete_message": "Bu organizasyonların tek sahibi sensin, bu yüzden <b>onlar da silinecek.</b>",
@@ -2711,8 +2710,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Aşağıdaki yedekleme kodlarını güvenli bir yerde sakla.",
"scan_the_qr_code_below_with_your_authenticator_app": "Aşağıdaki QR kodunu kimlik doğrulayıcı uygulamanla tara.",
"security_description": "Şifreni ve iki faktörlü kimlik doğrulama (2FA) gibi diğer güvenlik ayarlarını yönet.",
"sso_reauthentication_failed": "SSO yeniden kimlik doğrulaması başarısız oldu. Lütfen hesabınızı silmeyi tekrar deneyin.",
"sso_reauthentication_may_be_required_for_deletion": "SSO hesapları için Sil'i seçmek sizi kimlik sağlayıcınıza yönlendirebilir. Kimliğiniz doğrulanırsa, hesabınız otomatik olarak silinecektir.",
"sso_identity_confirmation_failed": "SSO kimlik doğrulaması başarısız oldu. Lütfen hesabınızı silmeyi tekrar deneyin.",
"sso_identity_confirmation_may_be_required_for_deletion": "SSO hesaplarında Sil'i seçmek, bu hesabı onaylamanız için sizi kimlik sağlayıcınıza yönlendirebilir. Aynı hesap onaylanırsa silme işlemi otomatik olarak devam eder.",
"two_factor_authentication": "İki faktörlü kimlik doğrulama",
"two_factor_authentication_description": "Şifren çalınması durumunda hesabına ekstra bir güvenlik katmanı ekle.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "İki faktörlü kimlik doğrulama etkinleştirildi. Lütfen kimlik doğrulayıcı uygulamandaki altı haneli kodu gir.",
@@ -3021,6 +3020,7 @@
"hidden_field_used_in_recall": "Gizli alan \"{hiddenField}\" soru {questionIndex}'te hatırlatılıyor.",
"hidden_field_used_in_recall_ending_card": "Gizli alan \"{hiddenField}\" Bitiş Kartında hatırlatılıyor",
"hidden_field_used_in_recall_welcome": "Gizli alan \"{hiddenField}\" Hoş Geldiniz kartında hatırlatılıyor.",
"hidden_fields_description": "Gizli verileri yanıtlayanlara göstermeden anketine aktar.",
"hide_back_button": "\"Geri\" düğmesini gizle",
"hide_back_button_description": "Ankette geri düğmesini gösterme",
"hide_block_settings": "Blok ayarlarını gizle",
@@ -3326,6 +3326,7 @@
"variable_used_in_recall": "“{variable}” değişkeni, {questionIndex}. soruda geri çağrılıyor.",
"variable_used_in_recall_ending_card": "{variable} değişkeni Bitiş Kartı'nda geri çağrılıyor.",
"variable_used_in_recall_welcome": "“{variable}” değişkeni Hoş Geldin Kartı'nda geri çağrılıyor.",
"variables_description": "Anketin boyunca değerleri tanımla ve hesapla.",
"verify_email_before_submission": "Göndermeden önce e-posta doğrula",
"verify_email_before_submission_description": "Sadece gerçek bir e-postaya sahip kişilerin yanıt vermesine izin ver.",
"visibility_and_recontact": "Görünürlük & Yeniden İletişim",
@@ -3597,7 +3598,6 @@
"no_identified_impressions": "Tanımlanmış kişilerden gösterim yok",
"no_responses_found": "Yanıt bulunamadı",
"nps_promoters_tooltip": "Yanıt verenlerin %{percentage}'si 9 veya 10 puan verdi (NPS tavsiye edenler).",
"open_response_details": "Açık yanıt detayları",
"other_values_found": "Diğer değerler bulundu",
"overall": "Genel",
"promoters": "Tavsiye edenler",
+4 -4
View File
@@ -2700,7 +2700,6 @@
"email_confirmation_does_not_match": "Email confirmation does not match.",
"enable_two_factor_authentication": "启用 双因素 认证",
"enter_the_code_from_your_authenticator_app_below": "从 你的 身份验证 应用 中 输入 代码 。",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "失去访问",
"or_enter_the_following_code_manually": "或者 手动输入 以下 代码:",
"organizations_delete_message": "您 是 这些 组织 的 唯一 所有者,因此它们 <b> 也将 被 删除。 </b>",
@@ -2711,8 +2710,8 @@
"save_the_following_backup_codes_in_a_safe_place": "请 将 以下 备份 代码 保存在 安全地 方。",
"scan_the_qr_code_below_with_your_authenticator_app": "用 你的 身份验证 应用 扫描 下方 的 二维码。",
"security_description": "管理你的密码和其他安全设置,如双因素认证 (2FA)。",
"sso_reauthentication_failed": "SSO 重新认证失败。请再次尝试删除的账。",
"sso_reauthentication_may_be_required_for_deletion": "对于 SSO 账户,选择删除可能会将重定向到身份提供商。如果您的身份确认成功,您的账户将自动删除。",
"sso_identity_confirmation_failed": "SSO 身份确认失败。请再次尝试删除的账。",
"sso_identity_confirmation_may_be_required_for_deletion": "对于 SSO 账户,选择删除可能会将重定向到身份提供商以确认此账户。如果确认的是同一账户,删除会自动继续。",
"two_factor_authentication": "双因素 认证",
"two_factor_authentication_description": "为你的账户增加额外的安全层,以防密码被盗。",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "双因素 认证 已启用 。 请输入 从 你的 身份验证 应用 中 的 六 位 数 字 代码 。",
@@ -3021,6 +3020,7 @@
"hidden_field_used_in_recall": "隐藏 字段 \"{hiddenField}\" 正在召回于问题 {questionIndex}。",
"hidden_field_used_in_recall_ending_card": "隐藏 字段 \"{hiddenField}\" 正在召回于结束 卡",
"hidden_field_used_in_recall_welcome": "隐藏 字段 \"{hiddenField}\" 正在召回于欢迎 卡 。",
"hidden_fields_description": "将隐藏数据传递到您的调查中,而不向受访者显示。",
"hide_back_button": "隐藏 \"返回\" 按钮",
"hide_back_button_description": "不 显示 调查 中 的 返回 按钮",
"hide_block_settings": "隐藏区块设置",
@@ -3326,6 +3326,7 @@
"variable_used_in_recall": "变量 \"{variable}\" 正在召回于问题 {questionIndex}。",
"variable_used_in_recall_ending_card": "变量 {variable} 正在召回于结束 卡片",
"variable_used_in_recall_welcome": "变量 \"{variable}\" 正在召回于欢迎 卡 。",
"variables_description": "在整个调查过程中定义和计算值。",
"verify_email_before_submission": "提交 之前 验证电子邮件",
"verify_email_before_submission_description": "仅允许 拥有 有效 电子邮件 的 人 回应。",
"visibility_and_recontact": "可见性与重新联系",
@@ -3597,7 +3598,6 @@
"no_identified_impressions": "没有已识别联系人的展示次数",
"no_responses_found": "未找到响应",
"nps_promoters_tooltip": "{percentage}% 的受访者给出了 9 或 10 分的评价(NPS 推荐者)。",
"open_response_details": "开放式回答详情",
"other_values_found": "找到其他值",
"overall": "整体",
"promoters": "推荐者",
+4 -4
View File
@@ -2700,7 +2700,6 @@
"email_confirmation_does_not_match": "Email confirmation does not match.",
"enable_two_factor_authentication": "啟用雙重驗證",
"enter_the_code_from_your_authenticator_app_below": "在下方輸入您驗證器應用程式中的程式碼。",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "無法存取",
"or_enter_the_following_code_manually": "或手動輸入下列程式碼:",
"organizations_delete_message": "您是這些組織的唯一擁有者,因此它們也 <b>將被刪除。</b>",
@@ -2711,8 +2710,8 @@
"save_the_following_backup_codes_in_a_safe_place": "將下列備份碼儲存在安全的地方。",
"scan_the_qr_code_below_with_your_authenticator_app": "使用您的驗證器應用程式掃描下方的 QR 碼。",
"security_description": "管理您的密碼和其他安全性設定,例如雙重驗證 (2FA)。",
"sso_reauthentication_failed": "SSO 重新驗證失敗。請再次嘗試刪除的帳。",
"sso_reauthentication_may_be_required_for_deletion": "對於 SSO 帳戶,選刪除可能會將重新導向至身分提供者。如果您的身分確認成功,您的帳戶將自動刪除。",
"sso_identity_confirmation_failed": "SSO 身分確認失敗。請再次嘗試刪除的帳。",
"sso_identity_confirmation_may_be_required_for_deletion": "對於 SSO 帳戶,選擇「刪除可能會將重新導向至身分提供者以確認此帳戶。如果確認的是同一個帳戶,刪除會自動繼續。",
"two_factor_authentication": "雙重驗證",
"two_factor_authentication_description": "在您的密碼被盜時,為您的帳戶新增額外的安全層。",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "已啟用雙重驗證。請輸入您驗證器應用程式中的六位數程式碼。",
@@ -3021,6 +3020,7 @@
"hidden_field_used_in_recall": "隱藏欄位 \"{hiddenField}\" 於問題 {questionIndex} 中被召回。",
"hidden_field_used_in_recall_ending_card": "隱藏欄位 \"{hiddenField}\" 於結束卡中被召回。",
"hidden_field_used_in_recall_welcome": "隱藏欄位 \"{hiddenField}\" 於歡迎卡中被召回。",
"hidden_fields_description": "將隱藏資料傳入你的問卷中,而不會顯示給受訪者。",
"hide_back_button": "隱藏「Back」按鈕",
"hide_back_button_description": "不要在問卷中顯示返回按鈕",
"hide_block_settings": "隱藏區塊設定",
@@ -3326,6 +3326,7 @@
"variable_used_in_recall": "變數 \"{variable}\" 於問題 {questionIndex} 中被召回。",
"variable_used_in_recall_ending_card": "變數 {variable} 於 結束 卡 中被召回。",
"variable_used_in_recall_welcome": "變數 \"{variable}\" 於 歡迎 Card 中被召回。",
"variables_description": "在整個問卷中定義和計算數值。",
"verify_email_before_submission": "提交前驗證電子郵件",
"verify_email_before_submission_description": "僅允許擁有真實電子郵件的人員回應。",
"visibility_and_recontact": "可見性與重新聯絡",
@@ -3597,7 +3598,6 @@
"no_identified_impressions": "沒有來自已識別聯絡人的曝光次數",
"no_responses_found": "找不到回應",
"nps_promoters_tooltip": "{percentage}% 的受訪者給予 9 或 10 分評價(NPS 推薦者)。",
"open_response_details": "開放式回覆詳情",
"other_values_found": "找到其他值",
"overall": "整體",
"promoters": "推廣者",
@@ -2,26 +2,23 @@
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { AuthorizationError } from "@formbricks/types/errors";
import { ZUserEmail } from "@formbricks/types/user";
import { WEBAPP_URL } from "@/lib/constants";
import { capturePostHogEvent } from "@/lib/posthog";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE } from "@/modules/account/constants";
import { deleteUserWithAccountDeletionAuthorization } from "@/modules/account/lib/account-deletion";
import { queueAccountDeletionAuditEvent } from "@/modules/account/lib/account-deletion-audit";
import { startAccountDeletionSsoReauthentication } from "@/modules/account/lib/account-deletion-sso-reauth";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
const ZDeleteUserConfirmation = z
.object({
confirmationEmail: z.string().trim().pipe(ZUserEmail),
password: z.string().max(128).optional(),
})
.strict();
const ZStartAccountDeletionSsoReauth = z
.object({
confirmationEmail: z.string().trim().pipe(ZUserEmail),
returnToUrl: z.string().trim().max(2048).pipe(z.url()),
returnToUrl: z.string().trim().max(2048).pipe(z.url()).optional(),
})
.strict();
@@ -29,31 +26,16 @@ const logAccountDeletionError = (userId: string, error: unknown) => {
logger.error({ error, userId }, "Account deletion failed");
};
export const startAccountDeletionSsoReauthenticationAction = authenticatedActionClient
.inputSchema(ZStartAccountDeletionSsoReauth)
const isSsoConfirmationRequiredError = (error: unknown) =>
error instanceof AuthorizationError && error.message === ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE;
export const deleteUserAction = authenticatedActionClient
.inputSchema(ZDeleteUserConfirmation)
.action(async ({ ctx, parsedInput }) => {
try {
await applyRateLimit(rateLimitConfigs.actions.accountDeletion, ctx.user.id);
const { confirmationEmail, returnToUrl } = parsedInput;
return await startAccountDeletionSsoReauthentication({
confirmationEmail,
returnToUrl,
userId: ctx.user.id,
});
} catch (error) {
logger.error({ error, userId: ctx.user.id }, "Account deletion SSO reauthentication failed");
throw error;
}
});
export const deleteUserAction = authenticatedActionClient.inputSchema(ZDeleteUserConfirmation).action(
withAuditLogging("deleted", "user", async ({ ctx, parsedInput }) => {
ctx.auditLoggingCtx.userId = ctx.user.id;
const userId = ctx.user.id;
try {
await applyRateLimit(rateLimitConfigs.actions.accountDeletion, ctx.user.id);
await applyRateLimit(rateLimitConfigs.actions.accountDeletion, userId);
const { confirmationEmail, password } = parsedInput;
@@ -61,16 +43,45 @@ export const deleteUserAction = authenticatedActionClient.inputSchema(ZDeleteUse
confirmationEmail,
password,
userEmail: ctx.user.email,
userId: ctx.user.id,
userId,
});
ctx.auditLoggingCtx.oldObject = oldUser;
await queueAccountDeletionAuditEvent({ oldUser, status: "success", targetUserId: userId });
capturePostHogEvent(ctx.user.id, "delete_account");
capturePostHogEvent(userId, "delete_account");
return { success: true };
} catch (error) {
logAccountDeletionError(ctx.user.id, error);
if (isSsoConfirmationRequiredError(error)) {
const { confirmationEmail, returnToUrl } = parsedInput;
try {
return {
ssoConfirmation: await startAccountDeletionSsoReauthentication({
confirmationEmail,
returnToUrl: returnToUrl ?? WEBAPP_URL,
userId,
}),
};
} catch (ssoConfirmationError) {
await queueAccountDeletionAuditEvent({
eventId: ctx.auditLoggingCtx.eventId,
status: "failure",
targetUserId: userId,
});
logger.error(
{ error: ssoConfirmationError, userId },
"Account deletion SSO identity confirmation failed"
);
throw ssoConfirmationError;
}
}
await queueAccountDeletionAuditEvent({
eventId: ctx.auditLoggingCtx.eventId,
status: "failure",
targetUserId: userId,
});
logAccountDeletionError(userId, error);
throw error;
}
})
);
});
@@ -11,7 +11,6 @@ import { getFormattedErrorMessage } from "@/lib/utils/helper";
import {
ACCOUNT_DELETION_CONFIRMATION_REQUIRED_ERROR_CODE,
ACCOUNT_DELETION_EMAIL_MISMATCH_ERROR_CODE,
ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE,
ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE,
DELETE_ACCOUNT_WRONG_PASSWORD_ERROR,
FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL,
@@ -20,7 +19,7 @@ import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { Input } from "@/modules/ui/components/input";
import { PasswordInput } from "@/modules/ui/components/password-input";
import { deleteUserAction, startAccountDeletionSsoReauthenticationAction } from "./actions";
import { deleteUserAction } from "./actions";
interface DeleteAccountModalProps {
requiresPasswordConfirmation: boolean;
@@ -29,6 +28,7 @@ interface DeleteAccountModalProps {
user: TUser;
isFormbricksCloud: boolean;
organizationsWithSingleOwner: TOrganization[];
isSsoIdentityConfirmationDisabled: boolean;
}
export const DeleteAccountModal = ({
@@ -38,6 +38,7 @@ export const DeleteAccountModal = ({
user,
isFormbricksCloud,
organizationsWithSingleOwner,
isSsoIdentityConfirmationDisabled,
}: Readonly<DeleteAccountModalProps>) => {
const { t } = useTranslation();
const [deleting, setDeleting] = useState(false);
@@ -65,10 +66,6 @@ export const DeleteAccountModal = ({
return t("workspace.settings.profile.wrong_password");
}
if (serverError === ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE) {
return t("workspace.settings.profile.google_sso_account_deletion_requires_setup");
}
if (serverError === ACCOUNT_DELETION_EMAIL_MISMATCH_ERROR_CODE) {
return t("workspace.settings.profile.email_confirmation_does_not_match");
}
@@ -78,39 +75,12 @@ export const DeleteAccountModal = ({
}
if (serverError === ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE) {
return t("workspace.settings.profile.sso_reauthentication_failed");
return t("workspace.settings.profile.sso_identity_confirmation_failed");
}
return null;
};
const startSsoReauthentication = async () => {
const result = await startAccountDeletionSsoReauthenticationAction({
confirmationEmail: inputValue,
returnToUrl: globalThis.location.href,
});
if (!result?.data) {
const fallbackErrorMessage = t("common.something_went_wrong_please_try_again");
const errorMessage =
getLocalizedDeletionErrorMessage(result?.serverError) ??
(result ? getFormattedErrorMessage(result) : fallbackErrorMessage);
logger.error({ errorMessage }, "Account deletion SSO reauthentication action failed");
toast.error(errorMessage || fallbackErrorMessage);
return;
}
await signIn(
result.data.provider,
{
callbackUrl: result.data.callbackUrl,
redirect: true,
},
result.data.authorizationParams
);
};
const deleteAccount = async () => {
try {
if (!hasValidConfirmation) {
@@ -123,37 +93,47 @@ export const DeleteAccountModal = ({
? {
confirmationEmail: inputValue,
password,
returnToUrl: globalThis.location.href,
}
: {
confirmationEmail: inputValue,
returnToUrl: globalThis.location.href,
}
);
if (result?.data?.ssoConfirmation) {
await signIn(
result.data.ssoConfirmation.provider,
{
callbackUrl: result.data.ssoConfirmation.callbackUrl,
redirect: true,
},
result.data.ssoConfirmation.authorizationParams
);
return;
}
if (!result?.data?.success) {
const fallbackErrorMessage = t("common.something_went_wrong_please_try_again");
let errorMessage = getLocalizedDeletionErrorMessage(result?.serverError) ?? fallbackErrorMessage;
if (result?.serverError === ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE) {
await startSsoReauthentication();
return;
} else if (result) {
errorMessage =
getLocalizedDeletionErrorMessage(result.serverError) ?? getFormattedErrorMessage(result);
}
const errorMessage = result
? (getLocalizedDeletionErrorMessage(result.serverError) ?? getFormattedErrorMessage(result))
: fallbackErrorMessage;
logger.error({ errorMessage }, "Account deletion action failed");
toast.error(errorMessage || fallbackErrorMessage);
return;
}
// Sign out with account deletion reason (no automatic redirect)
await signOutWithAudit({
reason: "account_deletion",
redirect: false, // Prevent NextAuth automatic redirect
clearWorkspaceId: true,
});
try {
await signOutWithAudit({
clearWorkspaceId: true,
reason: "account_deletion",
redirect: false,
});
} catch (error) {
logger.error({ error }, "Failed to sign out after account deletion");
}
// Manual redirect after signOut completes
if (isFormbricksCloud) {
globalThis.location.replace(FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL);
} else {
@@ -221,9 +201,9 @@ export const DeleteAccountModal = ({
id="deleteAccountConfirmation"
name="deleteAccountConfirmation"
/>
{!requiresPasswordConfirmation && (
{!requiresPasswordConfirmation && !isSsoIdentityConfirmationDisabled && (
<p className="mt-2 text-sm text-slate-600">
{t("workspace.settings.profile.sso_reauthentication_may_be_required_for_deletion")}
{t("workspace.settings.profile.sso_identity_confirmation_may_be_required_for_deletion")}
</p>
)}
{requiresPasswordConfirmation && (
-1
View File
@@ -4,7 +4,6 @@ export const ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE = "sso_reauth_failed"
export const ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE = "sso_reauth_required";
export const ACCOUNT_DELETION_EMAIL_MISMATCH_ERROR_CODE = "account_deletion_email_mismatch";
export const ACCOUNT_DELETION_CONFIRMATION_REQUIRED_ERROR_CODE = "account_deletion_confirmation_required";
export const ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE = "google_reauth_not_configured";
export const FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL =
"https://app.formbricks.com/s/clri52y3z8f221225wjdhsoo2";
@@ -0,0 +1,34 @@
import "server-only";
import { logger } from "@formbricks/logger";
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
export const queueAccountDeletionAuditEvent = async ({
eventId,
oldUser,
status,
targetUserId,
userId = targetUserId,
}: {
eventId?: string;
oldUser?: Record<string, unknown> | null;
status: "success" | "failure";
targetUserId: string;
userId?: string;
}) => {
try {
await queueAuditEventBackground({
action: "deleted",
targetType: "user",
userId,
userType: "user",
targetId: targetUserId,
organizationId: UNKNOWN_DATA,
oldObject: oldUser,
status,
...(eventId ? { eventId } : {}),
});
} catch (error) {
logger.error({ error, targetUserId, userId }, "Failed to queue account deletion audit event");
}
};
@@ -1,4 +1,3 @@
import jwt from "jsonwebtoken";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { ErrorCode } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
@@ -7,9 +6,8 @@ import { cache } from "@/lib/cache";
import { createAccountDeletionSsoReauthIntent, verifyAccountDeletionSsoReauthIntent } from "@/lib/jwt";
import { getUserAuthenticationData } from "@/lib/user/password";
import {
ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE,
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE,
ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE,
} from "@/modules/account/constants";
import {
completeAccountDeletionSsoReauthentication,
@@ -52,7 +50,6 @@ vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return {
...actual,
GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED: true,
SAML_PRODUCT: "formbricks",
SAML_TENANT: "formbricks.com",
WEBAPP_URL: "http://localhost:3000",
@@ -106,15 +103,13 @@ const storedSamlIntent = {
userId: samlIntent.userId,
};
const createIdToken = (authTime: number) => jwt.sign({ auth_time: authTime }, "test-secret");
const createAuthnInstant = (authTime: number) => new Date(authTime * 1000).toISOString();
const mockRedisConsume = (value: unknown) => {
const redisEval = vi.fn().mockResolvedValue(value === null ? null : JSON.stringify(value));
mockCache.getRedisClient.mockResolvedValueOnce({ eval: redisEval } as any);
return redisEval;
};
describe("account deletion SSO reauthentication", () => {
describe("account deletion SSO identity confirmation", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.spyOn(crypto, "randomUUID").mockReturnValue("intent-id" as ReturnType<typeof crypto.randomUUID>);
@@ -129,7 +124,7 @@ describe("account deletion SSO reauthentication", () => {
vi.restoreAllMocks();
});
test("starts SSO reauthentication with a signed, cached intent", async () => {
test("starts SSO identity confirmation with a signed, cached intent", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "google",
@@ -151,26 +146,45 @@ describe("account deletion SSO reauthentication", () => {
});
expect(result).toEqual({
authorizationParams: {
claims: JSON.stringify({
id_token: {
auth_time: {
essential: true,
},
},
}),
login_hint: intent.email,
max_age: "0",
prompt: "login",
},
callbackUrl: "http://localhost:3000/auth/account-deletion/sso/complete?intent=intent-token",
provider: "google",
});
});
test("starts Azure AD reauthentication with standard OIDC step-up params", async () => {
test("requests interactive login without freshness-only SSO authorization parameters", async () => {
mockCreateAccountDeletionSsoReauthIntent.mockReturnValue("intent-token");
for (const identityProvider of ["google", "azuread", "openid"] as const) {
mockGetUserAuthenticationData.mockResolvedValueOnce({
email: intent.email,
identityProvider,
identityProviderAccountId: `${identityProvider}-account-id`,
password: null,
} as any);
const result = await startAccountDeletionSsoReauthentication({
confirmationEmail: intent.email,
returnToUrl: "/environments/env-1/settings/profile",
userId: intent.userId,
});
expect(result.authorizationParams).toEqual({
login_hint: intent.email,
prompt: "login",
});
expect(result.authorizationParams).not.toHaveProperty("claims");
expect(result.authorizationParams).not.toHaveProperty("max_age");
}
});
test("starts GitHub SSO identity confirmation with account picker params", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "azuread",
identityProviderAccountId: intent.providerAccountId,
identityProvider: "github",
identityProviderAccountId: "github-account-id",
password: null,
} as any);
mockCreateAccountDeletionSsoReauthIntent.mockReturnValue("intent-token");
@@ -182,43 +196,13 @@ describe("account deletion SSO reauthentication", () => {
});
expect(result.authorizationParams).toEqual({
login_hint: intent.email,
max_age: "0",
prompt: "login",
login: intent.email,
prompt: "select_account",
});
expect(result.provider).toBe("azure-ad");
expect(result.provider).toBe("github");
});
test("extracts reauth intents only from the expected callback URL", () => {
expect(
getAccountDeletionSsoReauthIntentFromCallbackUrl(
"http://localhost:3000/auth/account-deletion/sso/complete?intent=intent-token"
)
).toBe("intent-token");
expect(
getAccountDeletionSsoReauthIntentFromCallbackUrl("http://localhost:3000/auth/login?intent=intent-token")
).toBeNull();
expect(
getAccountDeletionSsoReauthIntentFromCallbackUrl(
"https://evil.example/auth/account-deletion/sso/complete?intent=intent-token"
)
).toBeNull();
});
test("builds a safe profile redirect for SSO reauthentication callback failures", () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
expect(
getAccountDeletionSsoReauthFailureRedirectUrl({
error: new AuthorizationError(ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE),
intentToken: "intent-token",
})
).toBe(
`http://localhost:3000/environments/env-1/settings/profile?${ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM}=${ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE}`
);
});
test("starts SAML reauthentication with forced-authentication params", async () => {
test("starts SAML SSO identity confirmation with Jackson routing and ForceAuthn params", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "saml",
@@ -245,24 +229,32 @@ describe("account deletion SSO reauthentication", () => {
});
});
test("does not start SSO reauthentication for providers without verifiable freshness", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "github",
identityProviderAccountId: "github-account-id",
password: null,
} as any);
test("extracts confirmation intents only from the expected callback URL", () => {
expect(
getAccountDeletionSsoReauthIntentFromCallbackUrl(
"http://localhost:3000/auth/account-deletion/sso/complete?intent=intent-token"
)
).toBe("intent-token");
expect(
getAccountDeletionSsoReauthIntentFromCallbackUrl("http://localhost:3000/auth/login?intent=intent-token")
).toBeNull();
expect(
getAccountDeletionSsoReauthIntentFromCallbackUrl(
"https://evil.example/auth/account-deletion/sso/complete?intent=intent-token"
)
).toBeNull();
});
await expect(
startAccountDeletionSsoReauthentication({
confirmationEmail: intent.email,
returnToUrl: "/environments/env-1/settings/profile",
userId: intent.userId,
test("builds a safe profile redirect for SSO identity confirmation callback failures", () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
expect(
getAccountDeletionSsoReauthFailureRedirectUrl({
intentToken: "intent-token",
})
).rejects.toThrow(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
expect(mockCache.set).not.toHaveBeenCalled();
expect(mockCreateAccountDeletionSsoReauthIntent).not.toHaveBeenCalled();
).toBe(
`http://localhost:3000/environments/env-1/settings/profile?${ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM}=${ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE}`
);
});
test("falls back to the web app URL when the return URL is unsafe", async () => {
@@ -287,7 +279,7 @@ describe("account deletion SSO reauthentication", () => {
);
});
test("does not start SSO reauthentication for password-backed users", async () => {
test("does not start SSO identity confirmation for password-backed users", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "email",
@@ -306,7 +298,7 @@ describe("account deletion SSO reauthentication", () => {
expect(mockCache.set).not.toHaveBeenCalled();
});
test("does not start SSO reauthentication when the confirmation email mismatches", async () => {
test("does not start SSO identity confirmation when the confirmation email mismatches", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "google",
@@ -325,7 +317,7 @@ describe("account deletion SSO reauthentication", () => {
expect(mockCache.set).not.toHaveBeenCalled();
});
test("does not start SSO reauthentication without a linked SSO provider account", async () => {
test("does not start SSO identity confirmation without a linked SSO provider account", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "google",
@@ -359,11 +351,49 @@ describe("account deletion SSO reauthentication", () => {
returnToUrl: "/environments/env-1/settings/profile",
userId: intent.userId,
})
).rejects.toThrow("Unable to start account deletion SSO reauthentication");
).rejects.toThrow("Unable to start account deletion SSO identity confirmation");
expect(mockCreateAccountDeletionSsoReauthIntent).not.toHaveBeenCalled();
});
test("validates a matching SSO callback before the normal SSO handler runs", async () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
await expect(
validateAccountDeletionSsoReauthenticationCallback({
account: {
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).resolves.toBeUndefined();
expect(mockCache.get).toHaveBeenCalled();
expect(mockCache.del).not.toHaveBeenCalled();
});
test("validates a matching SAML callback without AuthnInstant freshness proof", async () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(samlIntent);
mockCache.get.mockResolvedValue({ ok: true, data: storedSamlIntent });
await expect(
validateAccountDeletionSsoReauthenticationCallback({
account: {
provider: "saml",
providerAccountId: samlIntent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).resolves.toBeUndefined();
expect(mockCache.get).toHaveBeenCalled();
expect(mockCache.del).not.toHaveBeenCalled();
});
test("fails SSO completion without consuming the intent when the callback provider does not match", async () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
@@ -421,11 +451,21 @@ describe("account deletion SSO reauthentication", () => {
expect(mockCache.get).not.toHaveBeenCalled();
});
test("rejects callbacks when the signed intent is for an unverifiable SSO provider", async () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue({
test("accepts GitHub callbacks because identity confirmation does not require freshness proof", async () => {
const githubIntent = {
...intent,
provider: "github",
providerAccountId: "github-account-id",
};
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(githubIntent);
mockCache.get.mockResolvedValue({
ok: true,
data: {
id: githubIntent.id,
provider: githubIntent.provider,
providerAccountId: githubIntent.providerAccountId,
userId: githubIntent.userId,
},
});
await expect(
@@ -437,9 +477,9 @@ describe("account deletion SSO reauthentication", () => {
} as any,
intentToken: "intent-token",
})
).rejects.toThrow(AuthorizationError);
).resolves.toBeUndefined();
expect(mockCache.get).not.toHaveBeenCalled();
expect(mockCache.get).toHaveBeenCalled();
expect(mockCache.del).not.toHaveBeenCalled();
});
@@ -460,145 +500,7 @@ describe("account deletion SSO reauthentication", () => {
expect(mockCache.get).not.toHaveBeenCalled();
});
test("validates a fresh SSO callback before the normal SSO handler runs", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
await expect(
validateAccountDeletionSsoReauthenticationCallback({
account: {
id_token: createIdToken(nowInSeconds),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).resolves.toBeUndefined();
expect(mockCache.get).toHaveBeenCalled();
expect(mockCache.del).not.toHaveBeenCalled();
});
test("rejects OIDC callbacks without an auth_time claim", async () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
await expect(
validateAccountDeletionSsoReauthenticationCallback({
account: {
id_token: jwt.sign({}, "test-secret"),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow(ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE);
expect(mockCache.get).not.toHaveBeenCalled();
});
test("validates a fresh SAML callback with an AuthnInstant", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(samlIntent);
mockCache.get.mockResolvedValue({ ok: true, data: storedSamlIntent });
await expect(
validateAccountDeletionSsoReauthenticationCallback({
account: {
authn_instant: createAuthnInstant(nowInSeconds),
provider: "saml",
providerAccountId: samlIntent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).resolves.toBeUndefined();
expect(mockCache.get).toHaveBeenCalled();
expect(mockCache.del).not.toHaveBeenCalled();
});
test("rejects SAML callbacks without an AuthnInstant", async () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(samlIntent);
await expect(
validateAccountDeletionSsoReauthenticationCallback({
account: {
provider: "saml",
providerAccountId: samlIntent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow(AuthorizationError);
expect(mockCache.get).not.toHaveBeenCalled();
});
test("rejects stale SAML AuthnInstant values without consuming the intent", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(samlIntent);
await expect(
completeAccountDeletionSsoReauthentication({
account: {
authn_instant: createAuthnInstant(nowInSeconds - 10 * 60),
provider: "saml",
providerAccountId: samlIntent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow(AuthorizationError);
expect(mockCache.del).not.toHaveBeenCalled();
expect(mockPrismaAccountFindUnique).not.toHaveBeenCalled();
});
test("rejects stale OIDC auth_time claims", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
await expect(
completeAccountDeletionSsoReauthentication({
account: {
id_token: createIdToken(nowInSeconds - 10 * 60),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow(AuthorizationError);
expect(mockPrismaAccountFindUnique).not.toHaveBeenCalled();
});
test("rejects OIDC auth_time claims too far in the future", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
await expect(
completeAccountDeletionSsoReauthentication({
account: {
id_token: createIdToken(nowInSeconds + 2 * 60),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow(AuthorizationError);
expect(mockPrismaAccountFindUnique).not.toHaveBeenCalled();
});
test("stores a deletion marker after fresh SSO reauthentication", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
test("stores a deletion marker after SSO identity confirmation", async () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
mockRedisConsume(storedIntent);
@@ -606,7 +508,6 @@ describe("account deletion SSO reauthentication", () => {
await completeAccountDeletionSsoReauthentication({
account: {
id_token: createIdToken(nowInSeconds),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
@@ -621,32 +522,7 @@ describe("account deletion SSO reauthentication", () => {
);
});
test("stores a deletion marker after fresh SAML reauthentication", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(samlIntent);
mockCache.get.mockResolvedValue({ ok: true, data: storedSamlIntent });
mockRedisConsume(storedSamlIntent);
mockPrismaAccountFindUnique.mockResolvedValue({ userId: samlIntent.userId } as any);
await completeAccountDeletionSsoReauthentication({
account: {
authn_instant: createAuthnInstant(nowInSeconds),
provider: "saml",
providerAccountId: samlIntent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
});
expect(mockCache.set).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining(storedSamlIntent),
5 * 60 * 1000
);
});
test("stores a deletion marker when the linked account is found through legacy user fields", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
mockRedisConsume(storedIntent);
@@ -655,7 +531,6 @@ describe("account deletion SSO reauthentication", () => {
await completeAccountDeletionSsoReauthentication({
account: {
id_token: createIdToken(nowInSeconds),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
@@ -680,7 +555,6 @@ describe("account deletion SSO reauthentication", () => {
});
test("fails SSO completion when the provider account belongs to another user", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
mockPrismaAccountFindUnique.mockResolvedValue({ userId: "other-user-id" } as any);
@@ -688,7 +562,6 @@ describe("account deletion SSO reauthentication", () => {
await expect(
completeAccountDeletionSsoReauthentication({
account: {
id_token: createIdToken(nowInSeconds),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
@@ -701,7 +574,6 @@ describe("account deletion SSO reauthentication", () => {
});
test("fails SSO completion when the cached intent does not match the signed intent", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({
ok: true,
@@ -714,7 +586,6 @@ describe("account deletion SSO reauthentication", () => {
await expect(
completeAccountDeletionSsoReauthentication({
account: {
id_token: createIdToken(nowInSeconds),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
@@ -728,7 +599,6 @@ describe("account deletion SSO reauthentication", () => {
});
test("fails SSO completion when the deletion marker cannot be cached", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
mockRedisConsume(storedIntent);
@@ -738,35 +608,32 @@ describe("account deletion SSO reauthentication", () => {
await expect(
completeAccountDeletionSsoReauthentication({
account: {
id_token: createIdToken(nowInSeconds),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow("Unable to complete account deletion SSO reauthentication");
).rejects.toThrow("Unable to complete account deletion SSO identity confirmation");
});
test("surfaces cache read failures while validating callbacks", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: false, error: cacheError });
await expect(
validateAccountDeletionSsoReauthenticationCallback({
account: {
id_token: createIdToken(nowInSeconds),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow("Unable to read account deletion SSO reauth value");
).rejects.toThrow("Unable to read account deletion SSO identity confirmation value");
});
test("requires a completed SSO reauthentication marker before deleting an SSO account", async () => {
test("requires a completed SSO identity confirmation marker before deleting an SSO account", async () => {
mockRedisConsume(null);
await expect(
@@ -778,7 +645,7 @@ describe("account deletion SSO reauthentication", () => {
).rejects.toThrow(AuthorizationError);
});
test("consumes a valid SSO reauthentication marker", async () => {
test("consumes a valid SSO identity confirmation marker", async () => {
const redisEval = mockRedisConsume({
...storedIntent,
completedAt: Date.now(),
@@ -807,37 +674,12 @@ describe("account deletion SSO reauthentication", () => {
providerAccountId: intent.providerAccountId,
userId: intent.userId,
})
).rejects.toThrow("Unable to consume account deletion SSO reauth value");
).rejects.toThrow("Unable to consume account deletion SSO identity confirmation value");
expect(mockCache.get).not.toHaveBeenCalled();
expect(mockCache.del).not.toHaveBeenCalled();
});
test("atomically consumes a valid SSO reauthentication marker from Redis", async () => {
const redisEval = vi.fn().mockResolvedValue(
JSON.stringify({
...storedIntent,
completedAt: Date.now(),
})
);
mockCache.getRedisClient.mockResolvedValueOnce({ eval: redisEval } as any);
await expect(
consumeAccountDeletionSsoReauthentication({
identityProvider: "google",
providerAccountId: intent.providerAccountId,
userId: intent.userId,
})
).resolves.toBeUndefined();
expect(redisEval).toHaveBeenCalledWith(expect.any(String), {
arguments: [],
keys: [expect.any(String)],
});
expect(mockCache.get).not.toHaveBeenCalled();
expect(mockCache.del).not.toHaveBeenCalled();
});
test("rejects unexpected Redis values while consuming a marker", async () => {
mockCache.getRedisClient.mockResolvedValueOnce({
eval: vi.fn().mockResolvedValue(42),
@@ -849,7 +691,7 @@ describe("account deletion SSO reauthentication", () => {
providerAccountId: intent.providerAccountId,
userId: intent.userId,
})
).rejects.toThrow("Unexpected cached account deletion SSO reauth value");
).rejects.toThrow("Unexpected cached account deletion SSO identity confirmation value");
});
test("surfaces atomic Redis failures while consuming a marker", async () => {
@@ -885,7 +727,7 @@ describe("account deletion SSO reauthentication", () => {
expect(mockCache.del).not.toHaveBeenCalled();
});
test("rejects an expired SSO reauthentication marker", async () => {
test("rejects an expired SSO identity confirmation marker", async () => {
mockRedisConsume({
...storedIntent,
completedAt: Date.now() - 6 * 60 * 1000,
@@ -1,25 +1,18 @@
import "server-only";
import type { IdentityProvider } from "@prisma/client";
import jwt from "jsonwebtoken";
import type { Account } from "next-auth";
import { createCacheKey } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { AuthorizationError, InvalidInputError } from "@formbricks/types/errors";
import { cache } from "@/lib/cache";
import {
GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED,
SAML_PRODUCT,
SAML_TENANT,
WEBAPP_URL,
} from "@/lib/constants";
import { SAML_PRODUCT, SAML_TENANT, WEBAPP_URL } from "@/lib/constants";
import { createAccountDeletionSsoReauthIntent, verifyAccountDeletionSsoReauthIntent } from "@/lib/jwt";
import { getUserAuthenticationData } from "@/lib/user/password";
import { getValidatedCallbackUrl } from "@/lib/utils/url";
import {
ACCOUNT_DELETION_CONFIRMATION_REQUIRED_ERROR_CODE,
ACCOUNT_DELETION_EMAIL_MISMATCH_ERROR_CODE,
ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE,
ACCOUNT_DELETION_SSO_REAUTH_CALLBACK_PATH,
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE,
@@ -33,13 +26,8 @@ import {
const ACCOUNT_DELETION_SSO_REAUTH_INTENT_TTL_MS = 10 * 60 * 1000;
const ACCOUNT_DELETION_SSO_REAUTH_MARKER_TTL_MS = 5 * 60 * 1000;
const SSO_AUTH_TIME_MAX_AGE_SECONDS = 5 * 60;
const SSO_AUTH_TIME_FUTURE_SKEW_SECONDS = 60;
type TSsoIdentityProvider = Exclude<IdentityProvider, "email">;
type TAccountWithSamlAuthnInstant = Account & {
authn_instant?: unknown;
};
type TStoredAccountDeletionSsoReauthIntent = {
id: string;
@@ -72,23 +60,6 @@ const NEXT_AUTH_PROVIDER_BY_IDENTITY_PROVIDER = {
saml: "saml",
} as const satisfies Record<TSsoIdentityProvider, string>;
const OIDC_REAUTH_PROVIDERS = new Set<TSsoIdentityProvider>([
"azuread",
...(GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED ? (["google"] as const) : []),
"openid",
]);
// GitHub OAuth does not return a verifiable auth_time/max_age proof, so it cannot secure this
// destructive action without another app-controlled step-up.
const FRESH_SSO_REAUTH_PROVIDERS = new Set<TSsoIdentityProvider>([...OIDC_REAUTH_PROVIDERS, "saml"]);
// Google only returns auth_time when it is explicitly requested as an ID token claim.
const GOOGLE_AUTH_TIME_CLAIMS_REQUEST = JSON.stringify({
id_token: {
auth_time: {
essential: true,
},
},
});
const getAccountDeletionSsoReauthIntentKey = (intentId: string) =>
createCacheKey.custom("account_deletion", "sso_reauth_intent", intentId);
@@ -106,28 +77,15 @@ const getSsoIdentityProviderOrThrow = (
return { provider: identityProvider, providerAccountId };
};
const assertSsoProviderSupportsFreshReauthentication = (provider: TSsoIdentityProvider) => {
if (provider === "google" && !GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED) {
logger.warn(
{ googleAccountDeletionReauthEnabled: GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED, provider },
"Google SSO account deletion reauthentication is not enabled"
);
throw new AuthorizationError(ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE);
}
if (!FRESH_SSO_REAUTH_PROVIDERS.has(provider)) {
logger.warn(
{ googleAccountDeletionReauthEnabled: GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED, provider },
"SSO provider does not support verifiable account deletion reauthentication"
);
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
}
};
const getAccountDeletionSsoReauthAuthorizationParams = (
provider: TSsoIdentityProvider,
email: string
): Record<string, string> => {
// This flow asks supported providers for an interactive login, but still only treats the callback
// as same-identity confirmation. Do not add max_age=0, Google auth_time claims, or AuthnInstant
// validation here unless the product decision changes back to strict step-up authentication.
// A future lower-friction alternative would be a short-lived email confirmation link that deletes
// the account after verifying the signed deletion intent, making the inbox the confirmation factor.
if (provider === "saml") {
return {
forceAuthn: "true",
@@ -137,23 +95,17 @@ const getAccountDeletionSsoReauthAuthorizationParams = (
};
}
if (OIDC_REAUTH_PROVIDERS.has(provider)) {
if (provider === "google") {
return {
claims: GOOGLE_AUTH_TIME_CLAIMS_REQUEST,
login_hint: email,
max_age: "0",
};
}
if (provider === "github") {
return {
login_hint: email,
max_age: "0",
prompt: "login",
login: email,
prompt: "select_account",
};
}
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
return {
login_hint: email,
prompt: "login",
};
};
const createAccountDeletionSsoReauthCallbackUrl = (intentToken: string) => {
@@ -162,14 +114,6 @@ const createAccountDeletionSsoReauthCallbackUrl = (intentToken: string) => {
return callbackUrl.toString();
};
const getAccountDeletionSsoReauthErrorCode = (error: unknown) => {
if (error instanceof Error && error.message === ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE) {
return ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE;
}
return ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE;
};
export const getAccountDeletionSsoReauthIntentFromCallbackUrl = (callbackUrl: string): string | null => {
const validatedCallbackUrl = getValidatedCallbackUrl(callbackUrl, WEBAPP_URL);
@@ -187,10 +131,8 @@ export const getAccountDeletionSsoReauthIntentFromCallbackUrl = (callbackUrl: st
};
export const getAccountDeletionSsoReauthFailureRedirectUrl = ({
error,
intentToken,
}: {
error: unknown;
intentToken: string | null;
}): string | null => {
if (!intentToken) {
@@ -208,11 +150,11 @@ export const getAccountDeletionSsoReauthFailureRedirectUrl = ({
const redirectUrl = new URL(validatedReturnToUrl);
redirectUrl.searchParams.set(
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
getAccountDeletionSsoReauthErrorCode(error)
ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE
);
return redirectUrl.toString();
} catch (redirectError) {
logger.error({ error: redirectError }, "Failed to resolve account deletion SSO reauth failure URL");
logger.error({ error: redirectError }, "Failed to resolve account deletion SSO confirmation failure URL");
return null;
}
};
@@ -224,9 +166,9 @@ const storeAccountDeletionSsoReauthIntent = async (intent: TStoredAccountDeletio
if (!result.ok) {
logger.error(
{ error: result.error, intentId: intent.id, userId: intent.userId },
"Failed to store SSO reauth intent"
"Failed to store SSO identity confirmation intent"
);
throw new Error("Unable to start account deletion SSO reauthentication");
throw new Error("Unable to start account deletion SSO identity confirmation");
}
};
@@ -237,9 +179,9 @@ const storeAccountDeletionSsoReauthMarker = async (marker: TAccountDeletionSsoRe
if (!result.ok) {
logger.error(
{ error: result.error, intentId: marker.id, userId: marker.userId },
"Failed to store account deletion SSO reauth marker"
"Failed to store account deletion SSO identity confirmation marker"
);
throw new Error("Unable to complete account deletion SSO reauthentication");
throw new Error("Unable to complete account deletion SSO identity confirmation");
}
};
@@ -249,13 +191,19 @@ const consumeCachedJsonValue = async <TValue>(key: string, logContext: Record<st
try {
redis = await cache.getRedisClient();
} catch (error) {
logger.error({ ...logContext, error, key }, "Failed to resolve Redis client for SSO reauth cache");
logger.error(
{ ...logContext, error, key },
"Failed to resolve Redis client for SSO identity confirmation cache"
);
throw error;
}
if (!redis) {
logger.error({ ...logContext, key }, "Redis is required to atomically consume SSO reauth cache value");
throw new Error("Unable to consume account deletion SSO reauth value");
logger.error(
{ ...logContext, key },
"Redis is required to atomically consume SSO identity confirmation cache value"
);
throw new Error("Unable to consume account deletion SSO identity confirmation value");
}
try {
@@ -278,13 +226,19 @@ const consumeCachedJsonValue = async <TValue>(key: string, logContext: Record<st
}
if (typeof serializedValue !== "string") {
logger.error({ ...logContext, key, serializedValue }, "Unexpected cached SSO reauth value");
throw new Error("Unexpected cached account deletion SSO reauth value");
logger.error(
{ ...logContext, key, serializedValue },
"Unexpected cached SSO identity confirmation value"
);
throw new Error("Unexpected cached account deletion SSO identity confirmation value");
}
return JSON.parse(serializedValue) as TValue;
} catch (error) {
logger.error({ ...logContext, error, key }, "Failed to atomically consume SSO reauth cache value");
logger.error(
{ ...logContext, error, key },
"Failed to atomically consume SSO identity confirmation cache value"
);
throw error;
}
};
@@ -293,8 +247,11 @@ const getCachedJsonValue = async <TValue>(key: string, logContext: Record<string
const cacheResult = await cache.get<TValue>(key);
if (!cacheResult.ok) {
logger.error({ ...logContext, error: cacheResult.error, key }, "Failed to read SSO reauth cache value");
throw new Error("Unable to read account deletion SSO reauth value");
logger.error(
{ ...logContext, error: cacheResult.error, key },
"Failed to read SSO identity confirmation cache value"
);
throw new Error("Unable to read account deletion SSO identity confirmation value");
}
return cacheResult.data;
@@ -379,89 +336,6 @@ const findLinkedSsoUserId = async ({
return legacyUser?.id ?? null;
};
const assertFreshAuthTime = (authTimeInSeconds: number, logContext: Record<string, unknown>) => {
const nowInSeconds = Math.floor(Date.now() / 1000);
const isTooOld = nowInSeconds - authTimeInSeconds > SSO_AUTH_TIME_MAX_AGE_SECONDS;
const isFromTheFuture = authTimeInSeconds - nowInSeconds > SSO_AUTH_TIME_FUTURE_SKEW_SECONDS;
if (isTooOld || isFromTheFuture) {
logger.warn(
{
...logContext,
ageSeconds: nowInSeconds - authTimeInSeconds,
authTimeInSeconds,
futureSkewSeconds: authTimeInSeconds - nowInSeconds,
maxAgeSeconds: SSO_AUTH_TIME_MAX_AGE_SECONDS,
},
"SSO account deletion reauthentication timestamp is not fresh"
);
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
}
};
const assertFreshOidcAuthTime = (provider: TSsoIdentityProvider, idToken?: string) => {
if (!OIDC_REAUTH_PROVIDERS.has(provider)) {
return;
}
if (!idToken) {
logger.warn({ provider }, "OIDC account deletion reauthentication callback is missing an ID token");
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
}
const decodedToken = jwt.decode(idToken);
if (!decodedToken || typeof decodedToken === "string") {
logger.warn({ provider }, "OIDC account deletion reauthentication callback has an invalid ID token");
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
}
const { auth_time: authTime } = decodedToken;
if (typeof authTime !== "number") {
logger.warn(
{ claimKeys: Object.keys(decodedToken), provider },
"OIDC account deletion reauthentication callback is missing numeric auth_time"
);
if (provider === "google") {
throw new AuthorizationError(ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE);
}
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
}
assertFreshAuthTime(authTime, { claim: "auth_time", provider });
};
const assertFreshSamlAuthnInstant = (
provider: TSsoIdentityProvider,
account: TAccountWithSamlAuthnInstant
) => {
if (provider !== "saml") {
return;
}
if (typeof account.authn_instant !== "string") {
logger.warn({ provider }, "SAML account deletion reauthentication callback is missing AuthnInstant");
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
}
const authnInstantTimestamp = Date.parse(account.authn_instant);
if (Number.isNaN(authnInstantTimestamp)) {
logger.warn({ provider }, "SAML account deletion reauthentication callback has invalid AuthnInstant");
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
}
assertFreshAuthTime(Math.floor(authnInstantTimestamp / 1000), { claim: "authn_instant", provider });
};
const assertFreshSsoAuthentication = (provider: TSsoIdentityProvider, account: Account) => {
assertSsoProviderSupportsFreshReauthentication(provider);
assertFreshOidcAuthTime(provider, account.id_token);
assertFreshSamlAuthnInstant(provider, account);
};
const getVerifiedAccountDeletionSsoReauthIntent = (intentToken: string) => {
const intent = verifyAccountDeletionSsoReauthIntent(intentToken);
const provider = normalizeSsoProvider(intent.provider);
@@ -470,8 +344,6 @@ const getVerifiedAccountDeletionSsoReauthIntent = (intentToken: string) => {
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
}
assertSsoProviderSupportsFreshReauthentication(provider);
return {
intent,
storedIntent: {
@@ -525,7 +397,6 @@ const validateAccountDeletionSsoReauthenticationCallbackContext = async ({
expectedProviderAccountId: storedIntent.providerAccountId,
provider: normalizedProvider,
});
assertFreshSsoAuthentication(normalizedProvider, account);
await assertStoredAccountDeletionSsoReauthIntentExists(storedIntent);
return { intent, normalizedProvider, storedIntent };
@@ -550,8 +421,7 @@ export const startAccountDeletionSsoReauthentication = async ({
userAuthenticationData.identityProvider,
userAuthenticationData.identityProviderAccountId
);
assertSsoProviderSupportsFreshReauthentication(provider);
logger.info({ provider, userId }, "Starting account deletion SSO reauthentication");
logger.info({ provider, userId }, "Starting account deletion SSO identity confirmation");
const intentId = crypto.randomUUID();
const validatedReturnToUrl = getValidatedCallbackUrl(returnToUrl, WEBAPP_URL) ?? WEBAPP_URL;
@@ -616,7 +486,7 @@ export const completeAccountDeletionSsoReauthentication = async ({
});
logger.info(
{ intentId: intent.id, provider: normalizedProvider, userId: intent.userId },
"Completed account deletion SSO reauthentication"
"Completed account deletion SSO identity confirmation"
);
};
@@ -646,7 +516,6 @@ export const consumeAccountDeletionSsoReauthentication = async ({
identityProvider,
providerAccountId
);
assertSsoProviderSupportsFreshReauthentication(provider);
const marker = await consumeCachedJsonValue<TAccountDeletionSsoReauthMarker>(
getAccountDeletionSsoReauthMarkerKey(userId),
@@ -22,9 +22,9 @@ const oldUser = {
};
const loadAccountDeletionModule = async ({
dangerouslyDisableSsoReauth = false,
dangerouslyDisableSsoConfirmation = false,
}: {
dangerouslyDisableSsoReauth?: boolean;
dangerouslyDisableSsoConfirmation?: boolean;
} = {}) => {
vi.resetModules();
@@ -35,7 +35,7 @@ const loadAccountDeletionModule = async ({
}));
vi.doMock("@/lib/constants", () => ({
DISABLE_ACCOUNT_DELETION_SSO_REAUTH: dangerouslyDisableSsoReauth,
DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION: dangerouslyDisableSsoConfirmation,
}));
vi.doMock("@/lib/organization/service", () => ({
@@ -81,7 +81,7 @@ describe("deleteUserWithAccountDeletionAuthorization", () => {
mocks.verifyUserPassword.mockResolvedValue(true);
});
test("requires the completed SSO reauthentication marker by default", async () => {
test("requires the completed SSO identity confirmation marker by default", async () => {
const { deleteUserWithAccountDeletionAuthorization } = await loadAccountDeletionModule();
await expect(
@@ -102,9 +102,9 @@ describe("deleteUserWithAccountDeletionAuthorization", () => {
expect(mocks.deleteUser).toHaveBeenCalledWith(user.id);
});
test("can dangerously bypass SSO reauthentication for passwordless SSO users", async () => {
test("can dangerously bypass SSO identity confirmation for passwordless SSO users", async () => {
const { deleteUserWithAccountDeletionAuthorization } = await loadAccountDeletionModule({
dangerouslyDisableSsoReauth: true,
dangerouslyDisableSsoConfirmation: true,
});
await expect(
@@ -118,7 +118,7 @@ describe("deleteUserWithAccountDeletionAuthorization", () => {
expect(mocks.consumeAccountDeletionSsoReauthentication).not.toHaveBeenCalled();
expect(mocks.loggerWarn).toHaveBeenCalledWith(
{ identityProvider: "google", userId: user.id },
"Account deletion SSO reauthentication bypassed by environment configuration"
"Account deletion SSO identity confirmation bypassed by environment configuration"
);
expect(mocks.deleteUser).toHaveBeenCalledWith(user.id);
});
@@ -131,7 +131,7 @@ describe("deleteUserWithAccountDeletionAuthorization", () => {
password: "hashed-password",
});
const { deleteUserWithAccountDeletionAuthorization } = await loadAccountDeletionModule({
dangerouslyDisableSsoReauth: true,
dangerouslyDisableSsoConfirmation: true,
});
await expect(
@@ -2,7 +2,7 @@ import "server-only";
import type { IdentityProvider } from "@prisma/client";
import { logger } from "@formbricks/logger";
import { AuthorizationError, InvalidInputError, OperationNotAllowedError } from "@formbricks/types/errors";
import { DISABLE_ACCOUNT_DELETION_SSO_REAUTH } from "@/lib/constants";
import { DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION } from "@/lib/constants";
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { getUserAuthenticationData, verifyUserPassword } from "@/lib/user/password";
import { deleteUser, getUser } from "@/lib/user/service";
@@ -29,10 +29,10 @@ const assertConfirmationEmailMatches = (confirmationEmail: string, expectedEmail
}
};
const canBypassSsoReauthentication = (identityProvider: IdentityProvider) =>
DISABLE_ACCOUNT_DELETION_SSO_REAUTH && identityProvider !== "email";
const canBypassSsoIdentityConfirmation = (identityProvider: IdentityProvider) =>
DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION && identityProvider !== "email";
const assertAccountDeletionSsoReauthentication = async ({
const assertAccountDeletionSsoIdentityConfirmation = async ({
identityProvider,
providerAccountId,
userId,
@@ -41,10 +41,10 @@ const assertAccountDeletionSsoReauthentication = async ({
providerAccountId: string | null;
userId: string;
}) => {
if (canBypassSsoReauthentication(identityProvider)) {
if (canBypassSsoIdentityConfirmation(identityProvider)) {
logger.warn(
{ identityProvider, userId },
"Account deletion SSO reauthentication bypassed by environment configuration"
"Account deletion SSO identity confirmation bypassed by environment configuration"
);
return;
}
@@ -95,7 +95,7 @@ export const deleteUserWithAccountDeletionAuthorization = async ({
}
if (!requiresPasswordConfirmationForAccountDeletion(userAuthenticationData)) {
await assertAccountDeletionSsoReauthentication({
await assertAccountDeletionSsoIdentityConfirmation({
identityProvider: userAuthenticationData.identityProvider,
providerAccountId: userAuthenticationData.identityProviderAccountId,
userId,
@@ -3,8 +3,8 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { deleteResponse, getResponseWithQuotas } from "@/lib/response/service";
import { createTag, getTagsByWorkspaceId } from "@/lib/tag/service";
import { deleteResponse, getResponse } from "@/lib/response/service";
import { createTag } from "@/lib/tag/service";
import { addTagToRespone, deleteTagOnResponse } from "@/lib/tagOnResponse/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
@@ -173,32 +173,6 @@ export const deleteResponseAction = authenticatedActionClient.inputSchema(ZDelet
})
);
const ZGetTagsByWorkspaceIdAction = z.object({
workspaceId: ZId,
});
export const getTagsByWorkspaceIdAction = authenticatedActionClient
.inputSchema(ZGetTagsByWorkspaceIdAction)
.action(async ({ parsedInput, ctx }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "workspaceTeam",
minPermission: "read",
workspaceId: parsedInput.workspaceId,
},
],
});
return await getTagsByWorkspaceId(parsedInput.workspaceId);
});
const ZGetResponseAction = z.object({
responseId: ZId,
});
@@ -222,5 +196,5 @@ export const getResponseAction = authenticatedActionClient
],
});
return await getResponseWithQuotas(parsedInput.responseId);
return await getResponse(parsedInput.responseId);
});
@@ -701,7 +701,7 @@ describe("authOptions", () => {
expect(mockUpdateUserLastLoginAt).toHaveBeenCalledWith(user.email);
});
test("should complete account deletion SSO reauthentication before finalizing sign-in", async () => {
test("should complete account deletion SSO identity confirmation before finalizing sign-in", async () => {
vi.resetModules();
const mockHandleSsoCallback = vi.fn().mockResolvedValueOnce(true);
@@ -773,7 +773,7 @@ describe("authOptions", () => {
expect(mockUpdateUserLastLoginAt).toHaveBeenCalledWith(user.email);
});
test("should redirect account deletion SSO reauthentication failures back to the profile page", async () => {
test("should redirect account deletion SSO identity confirmation failures back to the profile page", async () => {
vi.resetModules();
const mockHandleSsoCallback = vi.fn();
@@ -783,17 +783,15 @@ describe("authOptions", () => {
const mockGetAccountDeletionSsoReauthFailureRedirectUrl = vi
.fn()
.mockReturnValueOnce(
"http://localhost:3000/environments/env-id/settings/profile?accountDeletionError=google_reauth_not_configured"
"http://localhost:3000/environments/env-id/settings/profile?accountDeletionError=sso_reauth_failed"
);
const mockGetAccountDeletionSsoReauthIntentFromCallbackUrl = vi
.fn()
.mockReturnValueOnce("intent-token");
const reauthError = new Error(
"Google account deletion requires Google Auth Platform Session age claims to be enabled."
);
const confirmationError = new Error("SSO identity confirmation failed");
const mockValidateAccountDeletionSsoReauthenticationCallback = vi
.fn()
.mockRejectedValueOnce(reauthError);
.mockRejectedValueOnce(confirmationError);
vi.doMock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
@@ -842,11 +840,10 @@ describe("authOptions", () => {
const account = { provider: "google", type: "oauth", providerAccountId: "provider-123" } as any;
await expect(enterpriseAuthOptions.callbacks?.signIn?.({ user, account } as any)).resolves.toBe(
"http://localhost:3000/environments/env-id/settings/profile?accountDeletionError=google_reauth_not_configured"
"http://localhost:3000/environments/env-id/settings/profile?accountDeletionError=sso_reauth_failed"
);
expect(mockGetAccountDeletionSsoReauthFailureRedirectUrl).toHaveBeenCalledWith({
error: reauthError,
intentToken: "intent-token",
});
expect(mockHandleSsoCallback).not.toHaveBeenCalled();
+12 -37
View File
@@ -90,40 +90,6 @@ const handleCredentialsOrTokenSignIn = async ({
return true;
};
const maybeValidateAccountDeletionSsoReauth = async ({
account,
intentToken,
}: {
account: NonNullable<TSignInAccount>;
intentToken: string | null;
}) => {
if (!intentToken) {
return;
}
await validateAccountDeletionSsoReauthenticationCallback({
account,
intentToken,
});
};
const maybeCompleteAccountDeletionSsoReauth = async ({
account,
intentToken,
}: {
account: NonNullable<TSignInAccount>;
intentToken: string | null;
}) => {
if (!intentToken) {
return;
}
await completeAccountDeletionSsoReauthentication({
account,
intentToken,
});
};
const handleEnterpriseSsoSignIn = async ({
account,
callbackUrl,
@@ -139,7 +105,12 @@ const handleEnterpriseSsoSignIn = async ({
userEmail: string;
userId: string;
}) => {
await maybeValidateAccountDeletionSsoReauth({ account, intentToken });
if (intentToken) {
await validateAccountDeletionSsoReauthenticationCallback({
account,
intentToken,
});
}
const result = await handleSsoCallback({
user: user as TUser,
@@ -148,7 +119,12 @@ const handleEnterpriseSsoSignIn = async ({
});
if (result === true) {
await maybeCompleteAccountDeletionSsoReauth({ account, intentToken });
if (intentToken) {
await completeAccountDeletionSsoReauthentication({
account,
intentToken,
});
}
await finalizeSuccessfulSignIn({
userId,
@@ -489,7 +465,6 @@ export const authOptions: NextAuthOptions = {
});
} catch (error) {
const failureRedirectUrl = getAccountDeletionSsoReauthFailureRedirectUrl({
error,
intentToken: accountDeletionSsoReauthIntentToken,
});
@@ -112,4 +112,12 @@ describe("cube-config", () => {
await expect(import("./cube-config")).rejects.toThrow("Invalid environment variables");
});
test("fails at env validation when CUBEJS_API_SECRET is an empty string", async () => {
setTestEnv({
CUBEJS_API_SECRET: "",
});
await expect(import("./cube-config")).rejects.toThrow("Invalid environment variables");
});
});
@@ -1,11 +1,8 @@
import "server-only";
import jwt from "jsonwebtoken";
import { randomUUID } from "node:crypto";
import { ConfigurationError } from "@formbricks/types/errors";
import { env } from "@/lib/env";
export const CUBE_CONFIGURATION_ERROR_MESSAGE =
"Cube is not configured on this instance. Set CUBEJS_API_URL and CUBEJS_API_SECRET.";
export const CUBE_API_TOKEN_TTL_SECONDS = 5 * 60;
export const CUBE_QUERY_SCOPE = "xm:cube:query";
export const DEFAULT_CUBE_JWT_AUDIENCE = "formbricks-cube";
@@ -39,18 +36,12 @@ export const normalizeCubeApiUrl = (baseUrl: string): string => {
return `${normalizedBaseUrl}/cubejs-api/v1`;
};
export const getCubeApiCredentials = () => {
if (!env.CUBEJS_API_URL || !env.CUBEJS_API_SECRET) {
throw new ConfigurationError(CUBE_CONFIGURATION_ERROR_MESSAGE);
}
return {
apiUrl: normalizeCubeApiUrl(env.CUBEJS_API_URL),
apiSecret: env.CUBEJS_API_SECRET,
audience: env.CUBEJS_JWT_AUDIENCE ?? DEFAULT_CUBE_JWT_AUDIENCE,
issuer: env.CUBEJS_JWT_ISSUER ?? DEFAULT_CUBE_JWT_ISSUER,
};
};
export const getCubeApiCredentials = () => ({
apiUrl: normalizeCubeApiUrl(env.CUBEJS_API_URL),
apiSecret: env.CUBEJS_API_SECRET,
audience: env.CUBEJS_JWT_AUDIENCE ?? DEFAULT_CUBE_JWT_AUDIENCE,
issuer: env.CUBEJS_JWT_ISSUER ?? DEFAULT_CUBE_JWT_ISSUER,
});
export const createCubeApiToken = (
apiSecret: string,
@@ -1,7 +1,5 @@
import { redirect } from "next/navigation";
import { logger } from "@formbricks/logger";
import { responses } from "@/app/lib/api/response";
import { storeSamlAuthnInstantFromSamlResponse } from "@/modules/ee/auth/saml/lib/authn-instant";
import jackson from "@/modules/ee/auth/saml/lib/jackson";
interface SAMLCallbackBody {
@@ -14,7 +12,7 @@ export const POST = async (req: Request) => {
if (!jacksonInstance) {
return responses.forbiddenResponse("SAML SSO is not enabled in your Formbricks license");
}
const { connectionController, oauthController } = jacksonInstance;
const { oauthController } = jacksonInstance;
const formData = await req.formData();
const body = Object.fromEntries(formData.entries());
@@ -30,15 +28,5 @@ export const POST = async (req: Request) => {
return responses.internalServerErrorResponse("Failed to get redirect URL");
}
try {
await storeSamlAuthnInstantFromSamlResponse({
connectionController,
redirectUrl: redirect_url,
samlResponse: SAMLResponse,
});
} catch (error) {
logger.error({ error }, "Failed to persist SAML AuthnInstant");
}
return redirect(redirect_url);
};
@@ -1,7 +1,5 @@
import type { OAuthTokenReq } from "@boxyhq/saml-jackson";
import { logger } from "@formbricks/logger";
import { responses } from "@/app/lib/api/response";
import { consumeSamlAuthnInstantForCode } from "@/modules/ee/auth/saml/lib/authn-instant";
import jackson from "@/modules/ee/auth/saml/lib/jackson";
export const POST = async (req: Request) => {
@@ -15,13 +13,6 @@ export const POST = async (req: Request) => {
const formData = Object.fromEntries(body.entries());
const response = await oauthController.token(formData as unknown as OAuthTokenReq);
let authnInstant: string | null = null;
try {
authnInstant = await consumeSamlAuthnInstantForCode(formData.code);
} catch (error) {
logger.error({ error }, "Failed to consume SAML AuthnInstant");
}
return Response.json(authnInstant ? { ...response, authn_instant: authnInstant } : response);
return Response.json(response);
};
@@ -1,189 +0,0 @@
import { createHash } from "node:crypto";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { cache } from "@/lib/cache";
import {
consumeSamlAuthnInstantForCode,
getSamlAuthnInstantFromResponse,
getSamlAuthnInstantFromXml,
storeSamlAuthnInstantFromSamlResponse,
} from "./authn-instant";
vi.mock("@/lib/cache", () => ({
cache: {
del: vi.fn(),
get: vi.fn(),
set: vi.fn(),
},
}));
vi.mock("@boxyhq/saml20", () => ({
default: {
decryptXml: vi.fn(),
parseIssuer: vi.fn(),
validateSignature: vi.fn(),
},
}));
vi.mock("@boxyhq/saml-jackson/dist/saml/x509", () => ({
getDefaultCertificate: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
const saml20 = await import("@boxyhq/saml20");
const x509 = await import("@boxyhq/saml-jackson/dist/saml/x509");
const mockCache = vi.mocked(cache);
const mockSaml20 = vi.mocked(saml20.default);
const mockGetDefaultCertificate = vi.mocked(x509.getDefaultCertificate);
const connectionController = {
getConnections: vi.fn(),
};
const encodeSamlResponse = (xml: string) => Buffer.from(xml, "utf8").toString("base64");
const getSamlCodeHash = (code: string) => createHash("sha256").update(code).digest("hex");
const signedSamlResponse = `
<saml:Assertion>
<saml:AuthnStatement AuthnInstant="2026-05-04T12:30:00Z" />
</saml:Assertion>
`;
describe("SAML AuthnInstant handoff", () => {
beforeEach(() => {
vi.resetAllMocks();
mockCache.set.mockResolvedValue({ ok: true, data: undefined });
mockCache.del.mockResolvedValue({ ok: true, data: undefined });
mockGetDefaultCertificate.mockResolvedValue({
privateKey: "sp-private-key",
publicKey: "sp-public-key",
});
mockSaml20.parseIssuer.mockReturnValue("https://idp.example.com/metadata");
mockSaml20.decryptXml.mockReturnValue({ assertion: signedSamlResponse, decrypted: true });
mockSaml20.validateSignature.mockReturnValue(signedSamlResponse);
connectionController.getConnections.mockResolvedValue([
{
idpMetadata: {
publicKey: "trusted-public-key",
},
},
]);
});
test("extracts and normalizes AuthnInstant from signed SAML XML", () => {
expect(getSamlAuthnInstantFromXml(signedSamlResponse)).toBe("2026-05-04T12:30:00.000Z");
});
test("extracts AuthnInstant from the signature-validated SAML response", async () => {
const samlResponse = `
<samlp:Response>
<saml:Assertion>
<saml:AuthnStatement AuthnInstant="2026-05-04T12:00:00Z" />
</saml:Assertion>
</samlp:Response>
`;
await expect(
getSamlAuthnInstantFromResponse({
connectionController: connectionController as any,
samlResponse: encodeSamlResponse(samlResponse),
})
).resolves.toBe("2026-05-04T12:30:00.000Z");
expect(mockSaml20.validateSignature).toHaveBeenCalledWith(samlResponse, "trusted-public-key", null);
});
test("extracts AuthnInstant from encrypted signature-validated SAML responses", async () => {
const encryptedSignedResponse = `
<samlp:Response>
<saml:EncryptedAssertion>encrypted-assertion</saml:EncryptedAssertion>
</samlp:Response>
`;
const decryptedSignedResponse = `
<samlp:Response>
<saml:Assertion>
<saml:AuthnStatement AuthnInstant="2026-05-04T12:45:00Z" />
</saml:Assertion>
</samlp:Response>
`;
mockSaml20.validateSignature.mockReturnValue(encryptedSignedResponse);
mockSaml20.decryptXml.mockReturnValue({ assertion: decryptedSignedResponse, decrypted: true });
await expect(
getSamlAuthnInstantFromResponse({
connectionController: connectionController as any,
samlResponse: encodeSamlResponse(encryptedSignedResponse),
})
).resolves.toBe("2026-05-04T12:45:00.000Z");
expect(mockGetDefaultCertificate).toHaveBeenCalled();
expect(mockSaml20.decryptXml).toHaveBeenCalledWith(encryptedSignedResponse, {
privateKey: "sp-private-key",
});
});
test("stores signed AuthnInstant by the one-time OAuth code from the Jackson redirect", async () => {
const samlResponse = encodeSamlResponse(`
<samlp:Response>
<saml:Assertion>
<saml:AuthnStatement AuthnInstant="2026-05-04T12:30:00Z" />
</saml:Assertion>
</samlp:Response>
`);
await storeSamlAuthnInstantFromSamlResponse({
connectionController: connectionController as any,
redirectUrl: "http://localhost:3000/api/auth/callback/saml?code=oauth-code&state=state",
samlResponse,
});
expect(mockCache.set).toHaveBeenCalledWith(
expect.any(String),
{ authnInstant: "2026-05-04T12:30:00.000Z" },
5 * 60 * 1000
);
const cacheKey = mockCache.set.mock.calls[0][0] as string;
expect(cacheKey).toContain(getSamlCodeHash("oauth-code"));
expect(cacheKey).not.toContain("oauth-code");
});
test("does not store when the signed SAML XML has no AuthnInstant", async () => {
mockSaml20.validateSignature.mockReturnValue("<saml:Assertion />");
await storeSamlAuthnInstantFromSamlResponse({
connectionController: connectionController as any,
redirectUrl: "http://localhost:3000/api/auth/callback/saml?code=oauth-code&state=state",
samlResponse: encodeSamlResponse("<samlp:Response />"),
});
expect(mockCache.set).not.toHaveBeenCalled();
});
test("does not store when the SAML signature cannot be validated with known IdP metadata", async () => {
mockSaml20.validateSignature.mockReturnValue(null);
await storeSamlAuthnInstantFromSamlResponse({
connectionController: connectionController as any,
redirectUrl: "http://localhost:3000/api/auth/callback/saml?code=oauth-code&state=state",
samlResponse: encodeSamlResponse("<samlp:Response />"),
});
expect(mockCache.set).not.toHaveBeenCalled();
});
test("consumes a stored AuthnInstant for the token response", async () => {
mockCache.get.mockResolvedValue({
ok: true,
data: {
authnInstant: "2026-05-04T12:30:00.000Z",
},
});
await expect(consumeSamlAuthnInstantForCode("oauth-code")).resolves.toBe("2026-05-04T12:30:00.000Z");
const cacheKey = mockCache.get.mock.calls[0][0] as string;
expect(cacheKey).toContain(getSamlCodeHash("oauth-code"));
expect(cacheKey).not.toContain("oauth-code");
expect(mockCache.del).toHaveBeenCalledWith([cacheKey]);
});
});
@@ -1,185 +0,0 @@
import "server-only";
import saml20 from "@boxyhq/saml20";
import type { IConnectionAPIController, SAMLSSORecord } from "@boxyhq/saml-jackson";
import { getDefaultCertificate } from "@boxyhq/saml-jackson/dist/saml/x509";
import { createHash } from "node:crypto";
import { createCacheKey } from "@formbricks/cache";
import { logger } from "@formbricks/logger";
import { cache } from "@/lib/cache";
const SAML_AUTHN_INSTANT_TTL_MS = 5 * 60 * 1000;
type TSamlAuthnInstantCacheValue = {
authnInstant: string;
};
type TSamlConnection = Awaited<ReturnType<IConnectionAPIController["getConnections"]>>[number];
const authnInstantRegex = /<[\w:-]*AuthnStatement\b[^>]*\bAuthnInstant\s*=\s*["']([^"']+)["']/;
const encryptedAssertionRegex = /<[\w:-]*EncryptedAssertion\b/;
const getSamlCodeHash = (code: string) => createHash("sha256").update(code).digest("hex");
const getSamlAuthnInstantCacheKey = (code: string) =>
createCacheKey.custom("account_deletion", "saml_authn_instant", getSamlCodeHash(code));
const isSamlConnection = (connection: TSamlConnection): connection is SAMLSSORecord =>
"idpMetadata" in connection;
const getCodeFromRedirectUrl = (redirectUrl: string) => {
try {
return new URL(redirectUrl).searchParams.get("code");
} catch {
return null;
}
};
export const getSamlAuthnInstantFromXml = (samlXml: string): string | null => {
// Use .exec() instead of .match()
const match = authnInstantRegex.exec(samlXml);
const authnInstant = match?.[1];
if (!authnInstant) {
return null;
}
const authnInstantTimestamp = Date.parse(authnInstant);
if (Number.isNaN(authnInstantTimestamp)) {
return null;
}
return new Date(authnInstantTimestamp).toISOString();
};
const getSignedSamlXml = async ({
connectionController,
decodedSamlResponse,
}: {
connectionController: IConnectionAPIController;
decodedSamlResponse: string;
}) => {
const issuer = saml20.parseIssuer(decodedSamlResponse);
if (!issuer) {
return null;
}
const connections = await connectionController.getConnections({ entityId: issuer });
for (const connection of connections) {
if (!isSamlConnection(connection)) {
continue;
}
const { publicKey, thumbprint } = connection.idpMetadata;
if (!publicKey && !thumbprint) {
continue;
}
try {
const signedXml = saml20.validateSignature(decodedSamlResponse, publicKey ?? null, thumbprint ?? null);
if (signedXml) {
return signedXml;
}
} catch {
continue;
}
}
return null;
};
const getReadableSignedSamlXml = async (signedSamlXml: string) => {
if (!encryptedAssertionRegex.test(signedSamlXml)) {
return signedSamlXml;
}
const { privateKey } = await getDefaultCertificate();
return saml20.decryptXml(signedSamlXml, { privateKey }).assertion;
};
export const getSamlAuthnInstantFromResponse = async ({
connectionController,
samlResponse,
}: {
connectionController: IConnectionAPIController;
samlResponse: string;
}): Promise<string | null> => {
const decodedSamlResponse = Buffer.from(samlResponse, "base64").toString("utf8");
const signedSamlXml = await getSignedSamlXml({
connectionController,
decodedSamlResponse,
});
if (!signedSamlXml) {
return null;
}
return getSamlAuthnInstantFromXml(await getReadableSignedSamlXml(signedSamlXml));
};
export const storeSamlAuthnInstantFromSamlResponse = async ({
connectionController,
redirectUrl,
samlResponse,
}: {
connectionController: IConnectionAPIController;
redirectUrl: string;
samlResponse: string;
}) => {
const code = getCodeFromRedirectUrl(redirectUrl);
if (!code) {
return;
}
const authnInstant = await getSamlAuthnInstantFromResponse({
connectionController,
samlResponse,
}).catch((error: unknown) => {
logger.error({ error }, "Failed to extract SAML AuthnInstant");
return null;
});
if (!authnInstant) {
return;
}
const result = await cache.set(
getSamlAuthnInstantCacheKey(code),
{ authnInstant },
SAML_AUTHN_INSTANT_TTL_MS
);
if (!result.ok) {
logger.error({ error: result.error }, "Failed to store SAML AuthnInstant");
}
};
export const consumeSamlAuthnInstantForCode = async (code: unknown): Promise<string | null> => {
if (typeof code !== "string" || !code) {
return null;
}
const cacheKey = getSamlAuthnInstantCacheKey(code);
const result = await cache.get<TSamlAuthnInstantCacheValue>(cacheKey);
if (!result.ok) {
logger.error({ error: result.error }, "Failed to read SAML AuthnInstant");
return null;
}
if (!result.data) {
return null;
}
const deleteResult = await cache.del([cacheKey]);
if (!deleteResult.ok) {
logger.error({ error: deleteResult.error }, "Failed to consume SAML AuthnInstant");
}
return result.data.authnInstant;
};
@@ -9,6 +9,7 @@ import { Button } from "@/modules/ui/components/button";
interface RemovedFromOrganizationProps {
isFormbricksCloud: boolean;
isSsoIdentityConfirmationDisabled: boolean;
requiresPasswordConfirmation: boolean;
user: TUser;
}
@@ -16,6 +17,7 @@ interface RemovedFromOrganizationProps {
export const RemovedFromOrganization = ({
user,
isFormbricksCloud,
isSsoIdentityConfirmationDisabled,
requiresPasswordConfirmation,
}: Readonly<RemovedFromOrganizationProps>) => {
const { t } = useTranslation();
@@ -35,6 +37,7 @@ export const RemovedFromOrganization = ({
user={user}
isFormbricksCloud={isFormbricksCloud}
organizationsWithSingleOwner={[]}
isSsoIdentityConfirmationDisabled={isSsoIdentityConfirmationDisabled}
/>
<Button
onClick={() => {
@@ -2,7 +2,7 @@ import { Metadata } from "next";
import { getServerSession } from "next-auth";
import { notFound } from "next/navigation";
import { AuthenticationError } from "@formbricks/types/errors";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getHasNoOrganizations } from "@/lib/instance/service";
import { getOrganizationsByUserId } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service";
@@ -43,6 +43,7 @@ export const CreateOrganizationPage = async () => {
<RemovedFromOrganization
user={user}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isSsoIdentityConfirmationDisabled={DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION}
requiresPasswordConfirmation={requiresPasswordConfirmationForAccountDeletion(user)}
/>
);
+144
View File
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
import { StorageErrorCode } from "@formbricks/storage";
import { TResponseData } from "@formbricks/types/responses";
import { ZAllowedFileExtension } from "@formbricks/types/storage";
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import { TSurveyQuestion } from "@formbricks/types/surveys/types";
import {
isAllowedFileExtension,
@@ -12,6 +13,7 @@ import {
sanitizeFileName,
validateFileUploads,
validateSingleFile,
validateSurveyAllowsFileUpload,
} from "@/modules/storage/utils";
// Mock the getOriginalFileNameFromUrl function
@@ -351,6 +353,148 @@ describe("storage utils", () => {
});
});
describe("validateSurveyAllowsFileUpload", () => {
test("should allow a matching extension from a modern file upload block element", () => {
const blocks = [
{
id: "block1",
name: "Block 1",
elements: [
{
id: "element1",
type: "fileUpload" as const,
allowedFileExtensions: ["pdf"],
},
],
},
] as unknown as TSurveyBlock[];
expect(validateSurveyAllowsFileUpload({ fileName: "report.pdf", blocks })).toEqual({ ok: true });
});
test("should allow a matching extension from a legacy file upload question", () => {
const questions = [
{
id: "question1",
type: "fileUpload" as const,
allowedFileExtensions: ["png"],
},
] as TSurveyQuestion[];
expect(validateSurveyAllowsFileUpload({ fileName: "image.png", questions })).toEqual({ ok: true });
});
test("should allow any globally safe extension when a file upload has no survey restriction", () => {
const blocks = [
{
id: "block1",
name: "Block 1",
elements: [
{
id: "element1",
type: "fileUpload" as const,
},
],
},
] as unknown as TSurveyBlock[];
expect(validateSurveyAllowsFileUpload({ fileName: "report.pdf", blocks })).toEqual({ ok: true });
});
test("should reject surveys without file upload blocks or questions", () => {
const blocks = [
{
id: "block1",
name: "Block 1",
elements: [
{
id: "element1",
type: "openText" as const,
},
],
},
] as unknown as TSurveyBlock[];
const questions = [
{
id: "question1",
type: "openText" as const,
},
] as TSurveyQuestion[];
expect(validateSurveyAllowsFileUpload({ fileName: "report.pdf", blocks, questions })).toEqual({
ok: false,
reason: "no_file_upload_question",
});
});
test("should reject when no file upload entry allows the requested extension", () => {
const blocks = [
{
id: "block1",
name: "Block 1",
elements: [
{
id: "element1",
type: "fileUpload" as const,
allowedFileExtensions: ["jpg"],
},
{
id: "element2",
type: "fileUpload" as const,
allowedFileExtensions: ["png"],
},
],
},
] as unknown as TSurveyBlock[];
expect(validateSurveyAllowsFileUpload({ fileName: "report.pdf", blocks })).toEqual({
ok: false,
reason: "file_extension_not_allowed",
});
});
test("should allow when any file upload entry permits the requested extension", () => {
const blocks = [
{
id: "block1",
name: "Block 1",
elements: [
{
id: "element1",
type: "fileUpload" as const,
allowedFileExtensions: ["jpg"],
},
{
id: "element2",
type: "fileUpload" as const,
allowedFileExtensions: ["pdf"],
},
],
},
] as unknown as TSurveyBlock[];
expect(validateSurveyAllowsFileUpload({ fileName: "report.pdf", blocks })).toEqual({ ok: true });
});
test("should reject files without a globally safe extension even when the survey has an unrestricted upload", () => {
const questions = [
{
id: "question1",
type: "fileUpload" as const,
},
] as TSurveyQuestion[];
expect(validateSurveyAllowsFileUpload({ fileName: "report", questions })).toEqual({
ok: false,
reason: "file_extension_not_allowed",
});
expect(validateSurveyAllowsFileUpload({ fileName: "malware.exe", questions })).toEqual({
ok: false,
reason: "file_extension_not_allowed",
});
});
});
describe("isValidImageFile", () => {
test("should return true for valid image file extensions", () => {
expect(isValidImageFile("https://example.com/image.jpg")).toBe(true);
+82 -4
View File
@@ -2,6 +2,8 @@ import "server-only";
import { type StorageError, StorageErrorCode } from "@formbricks/storage";
import { TResponseData } from "@formbricks/types/responses";
import { TAllowedFileExtension, ZAllowedFileExtension } from "@formbricks/types/storage";
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import { TSurveyElementTypeEnum, TSurveyFileUploadElement } from "@formbricks/types/surveys/elements";
import { TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { responses } from "@/app/lib/api/response";
import { WEBAPP_URL } from "@/lib/constants";
@@ -57,15 +59,27 @@ export const sanitizeFileName = (rawFileName: string): string => {
return result;
};
/**
* Extracts the lowercase file extension from a file name
* @param fileName The name of the file
* @returns {string | null} The lowercase extension, or null when no extension exists
*/
const extractFileExtension = (fileName: string): string | null => {
const extension = fileName.split(".").pop()?.toLowerCase();
if (!extension || extension === fileName.toLowerCase()) return null;
return extension;
};
/**
* Validates if the file extension is allowed
* @param fileName The name of the file to validate
* @returns {boolean} True if the file extension is allowed, false otherwise
*/
export const isAllowedFileExtension = (fileName: string): boolean => {
// Extract the file extension
const extension = fileName.split(".").pop()?.toLowerCase();
if (!extension || extension === fileName.toLowerCase()) return false;
const extension = extractFileExtension(fileName);
if (!extension) return false;
// Check if the extension is in the allowed list
return Object.values(ZAllowedFileExtension.enum).includes(extension as TAllowedFileExtension);
@@ -77,7 +91,7 @@ export const validateSingleFile = (
): boolean => {
const fileName = getOriginalFileNameFromUrl(fileUrl);
if (!fileName) return false;
const extension = fileName.split(".").pop()?.toLowerCase();
const extension = extractFileExtension(fileName);
if (!extension) return false;
return !allowedFileExtensions || allowedFileExtensions.includes(extension as TAllowedFileExtension);
};
@@ -100,6 +114,70 @@ export const validateFileUploads = (data?: TResponseData, questions?: TSurveyQue
return true;
};
export type TSurveyFileUploadPermissionResult =
| {
ok: true;
}
| {
ok: false;
reason: "no_file_upload_question" | "file_extension_not_allowed";
};
const getAllowedFileExtensionFromFileName = (fileName: string): TAllowedFileExtension | null => {
const extension = extractFileExtension(fileName);
if (!extension) return null;
const extensionValidation = ZAllowedFileExtension.safeParse(extension);
return extensionValidation.success ? extensionValidation.data : null;
};
export const validateSurveyAllowsFileUpload = ({
fileName,
blocks,
questions,
}: {
fileName: string;
blocks?: TSurveyBlock[] | null;
questions?: TSurveyQuestion[] | null;
}): TSurveyFileUploadPermissionResult => {
const fileUploadConfigs = [
...(blocks ?? [])
.flatMap((block) => block.elements)
.filter((element) => element.type === TSurveyElementTypeEnum.FileUpload),
...(questions ?? []).filter((question) => question.type === TSurveyQuestionTypeEnum.FileUpload),
] as TSurveyFileUploadElement[];
if (fileUploadConfigs.length === 0) {
return {
ok: false,
reason: "no_file_upload_question",
};
}
const fileExtension = getAllowedFileExtensionFromFileName(fileName);
if (!fileExtension) {
return {
ok: false,
reason: "file_extension_not_allowed",
};
}
const isFileExtensionAllowed = fileUploadConfigs.some((fileUploadConfig) => {
const { allowedFileExtensions } = fileUploadConfig;
return allowedFileExtensions === undefined || allowedFileExtensions.includes(fileExtension);
});
return isFileExtensionAllowed
? { ok: true }
: {
ok: false,
reason: "file_extension_not_allowed",
};
};
export const isValidImageFile = (fileUrl: string): boolean => {
const fileName = getOriginalFileNameFromUrl(fileUrl);
if (!fileName || fileName.endsWith(".")) return false;
@@ -72,6 +72,7 @@ interface ElementsViewProps {
isStorageConfigured: boolean;
quotas: TSurveyQuota[];
isExternalUrlsAllowed: boolean;
moveHiddenFieldsToSettingsTab?: boolean;
}
export const ElementsView = ({
@@ -91,6 +92,7 @@ export const ElementsView = ({
isStorageConfigured = true,
quotas,
isExternalUrlsAllowed,
moveHiddenFieldsToSettingsTab = false,
}: ElementsViewProps) => {
const { t } = useTranslation();
const [logicDeletionWarning, setLogicDeletionWarning] = React.useState<{
@@ -919,23 +921,25 @@ export const ElementsView = ({
{!isCxMode && (
<>
<AddEndingCardButton localSurvey={localSurvey} addEndingCard={addEndingCard} />
<hr />
<HiddenFieldsCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
setActiveElementId={setActiveElementId}
activeElementId={activeElementId}
quotas={quotas}
/>
<SurveyVariablesCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
activeElementId={activeElementId}
setActiveElementId={setActiveElementId}
quotas={quotas}
/>
{!moveHiddenFieldsToSettingsTab && (
<>
<hr />
<HiddenFieldsCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
setActiveElementId={setActiveElementId}
activeElementId={activeElementId}
quotas={quotas}
/>
<SurveyVariablesCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
activeElementId={activeElementId}
setActiveElementId={setActiveElementId}
quotas={quotas}
/>
</>
)}
</>
)}
</div>
@@ -2,7 +2,7 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import * as Collapsible from "@radix-ui/react-collapsible";
import { EyeOff } from "lucide-react";
import { CheckIcon, EyeOff } from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -25,6 +25,7 @@ interface HiddenFieldsCardProps {
activeElementId: string | null;
setActiveElementId: (elementId: string | null) => void;
quotas: TSurveyQuota[];
inSettings?: boolean;
}
export const HiddenFieldsCard = ({
@@ -33,6 +34,7 @@ export const HiddenFieldsCard = ({
setActiveElementId,
setLocalSurvey,
quotas,
inSettings = false,
}: HiddenFieldsCardProps) => {
const open = activeElementId == "hidden";
const [hiddenField, setHiddenField] = useState<string>("");
@@ -149,6 +151,105 @@ export const HiddenFieldsCard = ({
// Auto Animate
const [parent] = useAutoAnimate();
const content = (
<Collapsible.CollapsibleContent
className={inSettings ? "flex flex-col" : `flex flex-col px-4 ${open && "pb-6"}`}
ref={parent}>
{inSettings && <hr className="py-1 text-slate-600" />}
<div className={cn("flex flex-wrap gap-2", inSettings ? "p-3" : "")} ref={parent}>
{localSurvey.hiddenFields?.fieldIds && localSurvey.hiddenFields?.fieldIds?.length > 0 ? (
localSurvey.hiddenFields?.fieldIds?.map((fieldId) => {
return (
<Tag
key={fieldId}
onDelete={(fieldId) => handleDeleteHiddenField(fieldId)}
tagId={fieldId}
tagName={fieldId}
/>
);
})
) : (
<p className="mt-2 text-sm italic text-slate-500">
{t("workspace.surveys.edit.no_hidden_fields_yet_add_first_one_below")}
</p>
)}
</div>
<form
className={inSettings ? "mt-5 p-3 pt-0" : "mt-5"}
onSubmit={(e) => {
e.preventDefault();
const existingElementIds = elements.map((element) => element.id);
const existingEndingCardIds = localSurvey.endings.map((ending) => ending.id);
const existingHiddenFieldIds = localSurvey.hiddenFields.fieldIds ?? [];
const existingVariableNames = localSurvey.variables.map((v) => v.name);
const validateIdError = validateId(
hiddenField,
existingElementIds,
existingEndingCardIds,
existingHiddenFieldIds,
existingVariableNames
);
if (validateIdError) {
toast.error(getValidateIdErrorMessage(validateIdError, "hiddenField", t));
return;
}
updateSurvey({
fieldIds: [...(localSurvey.hiddenFields?.fieldIds || []), hiddenField],
enabled: true,
});
toast.success(t("workspace.surveys.edit.hidden_field_added_successfully"));
setHiddenField("");
}}>
<Label htmlFor="hiddenField">{t("common.hidden_field")}</Label>
<div className="mt-2 flex items-center gap-2">
<Input
autoFocus
id="hiddenField"
name="hiddenField"
value={hiddenField}
onChange={(e) => setHiddenField(e.target.value.trim())}
placeholder={t("workspace.surveys.edit.type_field_id") + "..."}
/>
<Button variant="secondary" type="submit" className="h-10 whitespace-nowrap">
{t("workspace.surveys.edit.add_hidden_field_id")}
</Button>
</div>
</form>
</Collapsible.CollapsibleContent>
);
if (inSettings) {
return (
<Collapsible.Root
open={open}
onOpenChange={setOpen}
className={cn(
open ? "" : "hover:bg-slate-50",
"w-full space-y-2 rounded-lg border border-slate-300 bg-white"
)}>
<Collapsible.CollapsibleTrigger asChild className="h-full w-full cursor-pointer">
<div className="inline-flex px-4 py-4">
<div className="flex items-center pl-2 pr-5">
<CheckIcon
strokeWidth={3}
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"
/>
</div>
<div>
<p className="font-semibold text-slate-800">{t("common.hidden_fields")}</p>
<p className="mt-1 text-sm text-slate-500">
{t("workspace.surveys.edit.hidden_fields_description")}
</p>
</div>
</div>
</Collapsible.CollapsibleTrigger>
{content}
</Collapsible.Root>
);
}
return (
<div className={cn(open ? "shadow-lg" : "shadow-md", "group z-10 flex flex-row rounded-lg bg-white")}>
<div
@@ -173,69 +274,7 @@ export const HiddenFieldsCard = ({
</div>
</div>
</Collapsible.CollapsibleTrigger>
<Collapsible.CollapsibleContent className={`flex flex-col px-4 ${open && "pb-6"}`} ref={parent}>
<div className="flex flex-wrap gap-2" ref={parent}>
{localSurvey.hiddenFields?.fieldIds && localSurvey.hiddenFields?.fieldIds?.length > 0 ? (
localSurvey.hiddenFields?.fieldIds?.map((fieldId) => {
return (
<Tag
key={fieldId}
onDelete={(fieldId) => handleDeleteHiddenField(fieldId)}
tagId={fieldId}
tagName={fieldId}
/>
);
})
) : (
<p className="mt-2 text-sm italic text-slate-500">
{t("workspace.surveys.edit.no_hidden_fields_yet_add_first_one_below")}
</p>
)}
</div>
<form
className="mt-5"
onSubmit={(e) => {
e.preventDefault();
const existingElementIds = elements.map((element) => element.id);
const existingEndingCardIds = localSurvey.endings.map((ending) => ending.id);
const existingHiddenFieldIds = localSurvey.hiddenFields.fieldIds ?? [];
const existingVariableNames = localSurvey.variables.map((v) => v.name);
const validateIdError = validateId(
hiddenField,
existingElementIds,
existingEndingCardIds,
existingHiddenFieldIds,
existingVariableNames
);
if (validateIdError) {
toast.error(getValidateIdErrorMessage(validateIdError, "hiddenField", t));
return;
}
updateSurvey({
fieldIds: [...(localSurvey.hiddenFields?.fieldIds || []), hiddenField],
enabled: true,
});
toast.success(t("workspace.surveys.edit.hidden_field_added_successfully"));
setHiddenField("");
}}>
<Label htmlFor="hiddenField">{t("common.hidden_field")}</Label>
<div className="mt-2 flex items-center gap-2">
<Input
autoFocus
id="hiddenField"
name="hiddenField"
value={hiddenField}
onChange={(e) => setHiddenField(e.target.value.trim())}
placeholder={t("workspace.surveys.edit.type_field_id") + "..."}
/>
<Button variant="secondary" type="submit" className="h-10 whitespace-nowrap">
{t("workspace.surveys.edit.add_hidden_field_id")}
</Button>
</div>
</form>
</Collapsible.CollapsibleContent>
{content}
</Collapsible.Root>
</div>
);
@@ -8,10 +8,12 @@ import { TUserLocale } from "@formbricks/types/user";
import { TargetingCard } from "@/modules/ee/contacts/segments/components/targeting-card";
import { QuotasCard } from "@/modules/ee/quotas/components/quotas-card";
import { TTeamPermission } from "@/modules/ee/teams/workspace-teams/types/team";
import { HiddenFieldsCard } from "@/modules/survey/editor/components/hidden-fields-card";
import { HowToSendCard } from "@/modules/survey/editor/components/how-to-send-card";
import { RecontactOptionsCard } from "@/modules/survey/editor/components/recontact-options-card";
import { ResponseOptionsCard } from "@/modules/survey/editor/components/response-options-card";
import { SurveyPlacementCard } from "@/modules/survey/editor/components/survey-placement-card";
import { SurveyVariablesCard } from "@/modules/survey/editor/components/survey-variables-card";
import { TargetingLockedCard } from "@/modules/survey/editor/components/targeting-locked-card";
import { WhenToSendCard } from "@/modules/survey/editor/components/when-to-send-card";
@@ -32,6 +34,9 @@ interface SettingsViewProps {
locale: TUserLocale;
appSetupCompleted: boolean;
enterpriseLicenseRequestFormUrl: string;
moveHiddenFieldsToSettingsTab?: boolean;
activeElementId?: string | null;
setActiveElementId?: (elementId: string | null) => void;
}
export const SettingsView = ({
@@ -51,6 +56,9 @@ export const SettingsView = ({
locale,
appSetupCompleted,
enterpriseLicenseRequestFormUrl,
moveHiddenFieldsToSettingsTab = false,
activeElementId,
setActiveElementId,
}: SettingsViewProps) => {
const isAppSurvey = localSurvey.type === "app";
@@ -116,6 +124,27 @@ export const SettingsView = ({
<RecontactOptionsCard localSurvey={localSurvey} setLocalSurvey={setLocalSurvey} />
{isAppSurvey && <SurveyPlacementCard localSurvey={localSurvey} setLocalSurvey={setLocalSurvey} />}
{moveHiddenFieldsToSettingsTab && setActiveElementId && (
<>
<HiddenFieldsCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
setActiveElementId={setActiveElementId}
activeElementId={activeElementId ?? null}
quotas={quotas}
inSettings
/>
<SurveyVariablesCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
activeElementId={activeElementId ?? null}
setActiveElementId={setActiveElementId}
quotas={quotas}
inSettings
/>
</>
)}
</div>
);
};
@@ -50,6 +50,7 @@ interface SurveyEditorProps {
quotas: TSurveyQuota[];
isExternalUrlsAllowed: boolean;
publicDomain: string;
moveHiddenFieldsToSettingsTab?: boolean;
enterpriseLicenseRequestFormUrl: string;
}
@@ -79,6 +80,7 @@ export const SurveyEditor = ({
quotas,
isExternalUrlsAllowed,
publicDomain,
moveHiddenFieldsToSettingsTab = false,
enterpriseLicenseRequestFormUrl,
}: SurveyEditorProps) => {
const [activeView, setActiveView] = useState<TSurveyEditorTabs>("elements");
@@ -221,6 +223,7 @@ export const SurveyEditor = ({
isStorageConfigured={isStorageConfigured}
quotas={quotas}
isExternalUrlsAllowed={isExternalUrlsAllowed}
moveHiddenFieldsToSettingsTab={moveHiddenFieldsToSettingsTab}
/>
)}
@@ -269,6 +272,9 @@ export const SurveyEditor = ({
locale={locale}
appSetupCompleted={localWorkspace.appSetupCompleted}
enterpriseLicenseRequestFormUrl={enterpriseLicenseRequestFormUrl}
moveHiddenFieldsToSettingsTab={moveHiddenFieldsToSettingsTab}
activeElementId={activeElementId}
setActiveElementId={setActiveElementId}
/>
)}
@@ -2,7 +2,7 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import * as Collapsible from "@radix-ui/react-collapsible";
import { FileDigitIcon } from "lucide-react";
import { CheckIcon, FileDigitIcon } from "lucide-react";
import { type Dispatch, type SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import { TSurveyQuota } from "@formbricks/types/quota";
@@ -17,6 +17,7 @@ interface SurveyVariablesCardProps {
activeElementId: string | null;
setActiveElementId: (id: string | null) => void;
quotas: TSurveyQuota[];
inSettings?: boolean;
}
const variablesCardId = `fb-variables-${Date.now()}`;
@@ -27,6 +28,7 @@ export const SurveyVariablesCard = ({
activeElementId,
setActiveElementId,
quotas,
inSettings = false,
}: SurveyVariablesCardProps) => {
const open = activeElementId === variablesCardId;
const { t } = useTranslation();
@@ -41,6 +43,77 @@ export const SurveyVariablesCard = ({
}
};
const content = (
<Collapsible.CollapsibleContent
className={inSettings ? "flex flex-col" : `flex flex-col px-4 ${open && "pb-6"}`}
ref={parent}>
{inSettings && <hr className="py-1 text-slate-600" />}
<div className={cn("flex flex-col gap-2", inSettings ? "p-3" : "")} ref={parent}>
{localSurvey.variables.length > 0 ? (
localSurvey.variables.map((variable) => (
<SurveyVariablesCardItem
key={variable.id}
mode="edit"
variable={variable}
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
quotas={quotas}
/>
))
) : (
<p className="mt-2 text-sm italic text-slate-500">
{t("workspace.surveys.edit.no_variables_yet_add_first_one_below")}
</p>
)}
</div>
<div className={inSettings ? "p-3 pt-0" : ""}>
<SurveyVariablesCardItem
mode="create"
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
quotas={quotas}
/>
</div>
{localSurvey.variables.length > 0 && (
<div className={cn("mt-6", inSettings ? "p-3 pt-0" : "")}>
<OptionIds type="variables" variables={localSurvey.variables} />
</div>
)}
</Collapsible.CollapsibleContent>
);
if (inSettings) {
return (
<Collapsible.Root
open={open}
onOpenChange={setOpenState}
className={cn(
open ? "" : "hover:bg-slate-50",
"w-full space-y-2 rounded-lg border border-slate-300 bg-white"
)}>
<Collapsible.CollapsibleTrigger asChild className="h-full w-full cursor-pointer">
<div className="inline-flex px-4 py-4">
<div className="flex items-center pl-2 pr-5">
<CheckIcon
strokeWidth={3}
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"
/>
</div>
<div>
<p className="font-semibold text-slate-800">{t("common.variables")}</p>
<p className="mt-1 text-sm text-slate-500">
{t("workspace.surveys.edit.variables_description")}
</p>
</div>
</div>
</Collapsible.CollapsibleTrigger>
{content}
</Collapsible.Root>
);
}
return (
<div className={cn(open ? "shadow-lg" : "shadow-md", "group z-10 flex flex-row rounded-lg bg-white")}>
<div
@@ -67,39 +140,7 @@ export const SurveyVariablesCard = ({
</div>
</div>
</Collapsible.CollapsibleTrigger>
<Collapsible.CollapsibleContent className={`flex flex-col px-4 ${open && "pb-6"}`} ref={parent}>
<div className="flex flex-col gap-2" ref={parent}>
{localSurvey.variables.length > 0 ? (
localSurvey.variables.map((variable) => (
<SurveyVariablesCardItem
key={variable.id}
mode="edit"
variable={variable}
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
quotas={quotas}
/>
))
) : (
<p className="mt-2 text-sm italic text-slate-500">
{t("workspace.surveys.edit.no_variables_yet_add_first_one_below")}
</p>
)}
</div>
<SurveyVariablesCardItem
mode="create"
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
quotas={quotas}
/>
{localSurvey.variables.length > 0 && (
<div className="mt-6">
<OptionIds type="variables" variables={localSurvey.variables} />
</div>
)}
</Collapsible.CollapsibleContent>
{content}
</Collapsible.Root>
</div>
);
+5 -1
View File
@@ -9,6 +9,7 @@ import {
UNSPLASH_ACCESS_KEY,
} from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getPostHogFeatureFlag } from "@/lib/posthog";
import { getTranslate } from "@/lingodotdev/server";
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
@@ -92,10 +93,12 @@ export const SurveyEditorPage = async (props: {
]);
const quotas = isQuotasAllowed && survey ? await getQuotas(survey.id) : [];
const [workspaceLanguages, teamMemberDetails] = await Promise.all([
const [workspaceLanguages, teamMemberDetails, moveHiddenFieldsToSettingsTabFlag] = await Promise.all([
getWorkspaceLanguages(workspaceWithTeamIds.id),
getTeamMemberDetails(workspaceWithTeamIds.teamIds),
getPostHogFeatureFlag(session.user.id, "a-b_survey-editor_move-hidden-fields-to-settings"),
]);
const moveHiddenFieldsToSettingsTab = moveHiddenFieldsToSettingsTabFlag === "in-settings";
if (
!survey ||
@@ -139,6 +142,7 @@ export const SurveyEditorPage = async (props: {
isExternalUrlsAllowed={isExternalUrlsAllowed}
publicDomain={publicDomain}
enterpriseLicenseRequestFormUrl={ENTERPRISE_LICENSE_REQUEST_FORM_URL}
moveHiddenFieldsToSettingsTab={moveHiddenFieldsToSettingsTab}
/>
);
};
@@ -3,6 +3,7 @@
import { Workspace } from "@prisma/client";
import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { TResponseData } from "@formbricks/types/responses";
import { TSurvey, TSurveyStyling } from "@formbricks/types/surveys/types";
import { TWorkspaceStyling } from "@formbricks/types/workspace";
@@ -13,7 +14,7 @@ import { LinkSurveyWrapper } from "@/modules/survey/link/components/link-survey-
import { OfflineAlert } from "@/modules/survey/link/components/offline-alert";
import { getPrefillValue } from "@/modules/survey/link/lib/prefill";
import { getUserIdFromSearchParams } from "@/modules/survey/link/lib/user-id";
import { isRTLLanguage } from "@/modules/survey/link/lib/utils";
import { getWebAppLocale, isRTLLanguage } from "@/modules/survey/link/lib/utils";
import { SurveyInline } from "@/modules/ui/components/survey";
interface SurveyClientWrapperProps {
@@ -63,6 +64,17 @@ export const SurveyClientWrapper = ({
IS_FORMBRICKS_CLOUD,
}: SurveyClientWrapperProps) => {
const searchParams = useSearchParams();
const { i18n } = useTranslation();
useEffect(() => {
const webAppLocale = getWebAppLocale(languageCode, survey);
if (i18n.language !== webAppLocale) {
i18n.changeLanguage(webAppLocale).catch(() => {
i18n.changeLanguage("en-US");
});
}
}, [languageCode, survey, i18n]);
const skipPrefilled = searchParams.get("skipPrefilled") === "true";
const offlineSupport = searchParams.get("offlineSupport") === "true";
const userId = canReadUserIdFromUrl ? getUserIdFromSearchParams(searchParams) : undefined;
@@ -99,12 +99,7 @@ describe("useDeleteSurvey", () => {
0
);
resolveFetch?.(
new Response(JSON.stringify({ data: { id: "survey_1" } }), {
status: 200,
headers: { "Content-Type": "application/json" },
})
);
resolveFetch?.(new Response(null, { status: 204 }));
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: surveyKeys.lists() });
@@ -1,5 +1,10 @@
import { describe, expect, test } from "vitest";
import { buildSurveyListSearchParams } from "./v3-surveys-client";
import { afterEach, describe, expect, test, vi } from "vitest";
import type { V3ApiError } from "@/modules/api/lib/v3-client";
import { buildSurveyListSearchParams, deleteSurvey } from "./v3-surveys-client";
afterEach(() => {
vi.unstubAllGlobals();
});
describe("buildSurveyListSearchParams", () => {
test("emits only supported v3 params using normalized filter values", () => {
@@ -39,3 +44,39 @@ describe("buildSurveyListSearchParams", () => {
);
});
});
describe("deleteSurvey", () => {
test("treats 204 No Content as a successful delete", async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
vi.stubGlobal("fetch", fetchMock);
await expect(deleteSurvey("survey_1")).resolves.toBeUndefined();
expect(fetchMock).toHaveBeenCalledWith("/api/v3/surveys/survey_1", {
method: "DELETE",
cache: "no-store",
});
});
test("maps v3 problem responses to V3ApiError", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
Response.json(
{
status: 403,
detail: "You are not authorized to access this resource",
code: "forbidden",
},
{ status: 403 }
)
)
);
await expect(deleteSurvey("survey_1")).rejects.toMatchObject<V3ApiError>({
status: 403,
detail: "You are not authorized to access this resource",
code: "forbidden",
});
});
});
@@ -13,12 +13,6 @@ type TV3SurveyListResponse = {
meta: TSurveyListPage["meta"];
};
type TV3DeleteSurveyResponse = {
data: {
id: string;
};
};
export type TSurveyListPage = {
data: TSurveyListItem[];
meta: {
@@ -122,7 +116,7 @@ export async function listSurveys({
};
}
export async function deleteSurvey(surveyId: string): Promise<{ id: string }> {
export async function deleteSurvey(surveyId: string): Promise<void> {
const response = await fetch(`/api/v3/surveys/${surveyId}`, {
method: "DELETE",
cache: "no-store",
@@ -131,7 +125,4 @@ export async function deleteSurvey(surveyId: string): Promise<{ id: string }> {
if (!response.ok) {
throw await parseV3ApiError(response);
}
const body = (await response.json()) as TV3DeleteSurveyResponse;
return body.data;
}
@@ -118,6 +118,26 @@ describe("workspace lib", () => {
expectNoFrdSideEffects();
});
test("seeds the default contact attribute keys when creating a workspace", async () => {
const createdWorkspace = { ...baseWorkspace, id: "p-defaults" };
vi.mocked(prisma.workspace.create).mockResolvedValueOnce(createdWorkspace as any);
await createWorkspace("org1", { name: "Workspace defaults" });
const createArgs = vi.mocked(prisma.workspace.create).mock.calls[0][0];
const attributeCreate = (createArgs.data as any).contactAttributeKeys.create as Array<{
key: string;
type: string;
isUnique?: boolean;
}>;
expect(attributeCreate.map((a) => a.key).sort()).toEqual(
["email", "firstName", "language", "lastName", "userId"].sort()
);
expect(attributeCreate.every((a) => a.type === "default")).toBe(true);
const uniqueKeys = attributeCreate.filter((a) => a.isUnique).map((a) => a.key);
expect(uniqueKeys.sort()).toEqual(["email", "userId"].sort());
});
test("creates workspace without teams and does not auto-link any FRD", async () => {
const createdWorkspace = { ...baseWorkspace, id: "p3" };
vi.mocked(prisma.workspace.create).mockResolvedValueOnce(createdWorkspace as any);
@@ -9,6 +9,41 @@ import { TWorkspace, TWorkspaceUpdateInput, ZWorkspaceUpdateInput } from "@formb
import { validateInputs } from "@/lib/utils/validate";
import { deleteFilesByWorkspaceId } from "@/modules/storage/service";
const DEFAULT_CONTACT_ATTRIBUTE_KEYS: Prisma.ContactAttributeKeyCreateWithoutWorkspaceInput[] = [
{
key: "userId",
name: "User Id",
description: "The user id of a contact",
type: "default",
isUnique: true,
},
{
key: "email",
name: "Email",
description: "The email of a contact",
type: "default",
isUnique: true,
},
{
key: "firstName",
name: "First Name",
description: "Your contact's first name",
type: "default",
},
{
key: "lastName",
name: "Last Name",
description: "Your contact's last name",
type: "default",
},
{
key: "language",
name: "Language",
description: "The language preference of a contact",
type: "default",
},
];
const selectWorkspace = {
id: true,
createdAt: true,
@@ -76,6 +111,9 @@ export const createWorkspace = async (
...data,
name: workspaceInput.name,
organizationId,
contactAttributeKeys: {
create: DEFAULT_CONTACT_ATTRIBUTE_KEYS,
},
},
select: selectWorkspace,
});
-1
View File
@@ -21,7 +21,6 @@
"dependencies": {
"@cubejs-client/core": "1.6.6",
"@boxyhq/saml-jackson": "26.2.0",
"@boxyhq/saml20": "1.15.2",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/modifiers": "9.0.0",
"@dnd-kit/sortable": "10.0.0",
+6 -5
View File
@@ -46,14 +46,15 @@ The intended defaults are:
- self-hosted / single-tenant clusters: bundled controller mode
- shared clusters with an existing platform controller: external-controller mode
## Cube.js for XM Suite v5
## Cube
XM Suite v5 dashboard and analysis features require Cube.js. Set `cube.enabled=true` to deploy an
internal Cube service from this chart, or provide an external Cube endpoint.
Cube is part of the baseline Formbricks v5 stack and is deployed by this chart by default
(`cube.enabled: true`).
- For chart-managed Cube, set `deployment.env.CUBEJS_API_URL` to `http://formbricks-cube:4000`
- For the chart-managed Cube, `deployment.env.CUBEJS_API_URL` should point at `http://formbricks-cube:4000`
when using the default release name.
- For external Cube, set `deployment.env.CUBEJS_API_URL` to your Cube endpoint.
- For an external Cube, set `cube.enabled: false` and point `deployment.env.CUBEJS_API_URL` at your
endpoint.
- Provide `CUBEJS_API_SECRET` through your existing secret management flow, such as the generated app secret override or `deployment.envFrom`.
- Provide `CUBEJS_DB_*` connection variables to the Cube deployment through `cube.envFrom` or `cube.env`.
- Keep `cube.replicas=1` while `cube.env.CUBEJS_CACHE_AND_QUEUE_DRIVER` is `memory`. Configure Cube Store before running multiple Cube replicas.
+6 -8
View File
@@ -96,8 +96,8 @@ deployment:
# nameSuffix: app-secrets
# Environment variables passed to the app container.
# XM Suite v5 analytics requires an external Cube endpoint when using Helm:
# set deployment.env.CUBEJS_API_URL and provide CUBEJS_API_SECRET through a Secret referenced by envFrom/existingSecret.
# Cube is bundled by default (see the `cube` section below). To use an external Cube cluster instead,
# set `cube.enabled: false` and provide CUBEJS_API_URL / CUBEJS_API_SECRET here via deployment.env or envFrom.
env: {}
# Tolerations for scheduling pods on tainted nodes
@@ -561,8 +561,10 @@ serviceMonitor:
# Cube.js Analytics Configuration
##########################################################
cube:
# Optional internal Cube.js service for XM Suite v5 analytics.
enabled: false
# Cube semantic-layer service used by Formbricks analytics. Bundled by default.
# Set to false only if you want to point the app at an external Cube cluster
# via deployment.env.CUBEJS_API_URL (CUBEJS_API_SECRET must still be provided).
enabled: true
replicas: 1
image:
@@ -900,10 +902,6 @@ hub:
affinity: {}
topologySpreadConstraints: []
# XM Suite v5 analytics also requires Cube. Use cube.enabled=true to deploy
# the internal chart-managed Cube service, or set deployment.env.CUBEJS_API_URL
# to an operator-managed Cube endpoint.
# Upgrade migration job runs goose + river before Helm upgrades Hub resources.
# Fresh installs run the same migrations through the Hub deployment init container.
migration:
-1
View File
@@ -155,7 +155,6 @@ services:
<<: *hub-runtime-environment
cube:
profiles: ["xm"]
image: cubejs/cube:v1.6.6
env_file:
- apps/web/.env
+4 -4
View File
@@ -30,13 +30,13 @@ That's it! After running the command and providing the required information, vis
## Formbricks Hub and Cube
The stack includes the [Formbricks Hub](https://github.com/formbricks/hub) API (`ghcr.io/formbricks/hub`) and can also run a bundled Cube.js service for XM Suite v5 analytics. Hub and Cube share the same database as Formbricks by default, and Cube is enabled through the optional Docker Compose `xm` profile.
The stack includes the [Formbricks Hub](https://github.com/formbricks/hub) API (`ghcr.io/formbricks/hub`) and the bundled Cube service. Hub and Cube share the same database as Formbricks by default and both start as part of the baseline `docker compose up`.
- **Migrations**: A `hub-migrate` service runs Hub's database migrations (goose + river) before the Hub API starts. It runs on every `docker compose up` and is idempotent.
- **Production** (`docker/docker-compose.yml`): Set `HUB_API_KEY` (required). `HUB_API_URL` defaults to `http://hub:8080` so the Formbricks app can reach Hub inside the compose network. To enable XM Suite v5 analytics, set `COMPOSE_PROFILES=xm` and `CUBEJS_API_SECRET`; `CUBEJS_API_URL` defaults to `http://cube:4000`. Cube JWT issuer/audience default to `formbricks-web` and `formbricks-cube`, and the bundled Cube service exposes only `meta,data` API scopes. Override `HUB_DATABASE_URL` and `CUBEJS_DB_*` only if Hub or Cube should use a separate database. The Hub image tracks `:latest` by default so `formbricks.sh update` advances Hub in lockstep with the app. `hub` and `hub-migrate` always resolve to the same image. To pin to an immutable reference, set `HUB_IMAGE_REF` in `docker/.env` to either a tag (e.g. `:0.3.0`) or a digest (e.g. `@sha256:14db7b3d...`).
- **Development** (`docker-compose.dev.yml`): Hub uses a dedicated local `hub` database and `HUB_API_KEY` defaults to `dev-api-key`. The dev stack starts `hub` plus `hub-worker`; set `EMBEDDING_PROVIDER`, `EMBEDDING_MODEL`, and any provider credentials in the repo root `.env` to enable Hub embeddings locally. See the [Hub embeddings environment reference](https://hub.formbricks.com/reference/environment-variables/#embeddings) for provider-specific values. Cube is behind the `xm` profile, `CUBEJS_API_URL` defaults to `http://localhost:4000`, and `pnpm dev:setup` generates `CUBEJS_API_SECRET` in the repo root `.env`. The Hub image is pinned to a semver tag (`hub`, `hub-worker`, and `hub-migrate` share the same value); override `HUB_IMAGE_TAG` in the repo root `.env` to test a specific Hub release.
- **Production** (`docker/docker-compose.yml`): Set `HUB_API_KEY` and `CUBEJS_API_SECRET` (both required). `HUB_API_URL` defaults to `http://hub:8080` and `CUBEJS_API_URL` defaults to `http://cube:4000` so the Formbricks app reaches Hub and Cube inside the compose network. Cube JWT issuer/audience default to `formbricks-web` and `formbricks-cube`, and the bundled Cube service exposes only `meta,data` API scopes. Override `HUB_DATABASE_URL` and `CUBEJS_DB_*` only if Hub or Cube should use a separate database. The Hub image tracks `:latest` by default so `formbricks.sh update` advances Hub in lockstep with the app. `hub` and `hub-migrate` always resolve to the same image. To pin to an immutable reference, set `HUB_IMAGE_REF` in `docker/.env` to either a tag (e.g. `:0.3.0`) or a digest (e.g. `@sha256:14db7b3d...`).
- **Development** (`docker-compose.dev.yml`): Hub uses a dedicated local `hub` database and `HUB_API_KEY` defaults to `dev-api-key`. The dev stack starts `hub` plus `hub-worker`; set `EMBEDDING_PROVIDER`, `EMBEDDING_MODEL`, and any provider credentials in the repo root `.env` to enable Hub embeddings locally. See the [Hub embeddings environment reference](https://hub.formbricks.com/reference/environment-variables/#embeddings) for provider-specific values. Cube starts with the dev stack, `CUBEJS_API_URL` defaults to `http://localhost:4000`, and `pnpm dev:setup` generates `CUBEJS_API_SECRET` in the repo root `.env`. The Hub image is pinned to a semver tag (`hub`, `hub-worker`, and `hub-migrate` share the same value); override `HUB_IMAGE_TAG` in the repo root `.env` to test a specific Hub release.
In development, Hub is exposed locally on port **8080**. When the `xm` profile is enabled, Cube is exposed on **4000** (with the Cube playground on **4001**). In production Docker Compose, Hub stays internal to the compose network at `http://hub:8080`; Cube also stays internal at `http://cube:4000` when enabled.
In development, Hub is exposed locally on port **8080** and Cube on **4000** (with the Cube playground on **4001**). In production Docker Compose, both stay internal to the compose network at `http://hub:8080` and `http://cube:4000`.
The one-click Traefik installer exposes Hub-backed FeedbackRecords on the Formbricks origin at
`/api/v3/feedbackRecords` and `/v1/feedback-records`. Traefik uses Formbricks gateway auth, rewrites the v3
+10 -3
View File
@@ -38,7 +38,7 @@ x-environment: &environment
# Hub database URL (optional). Default: same Postgres as Formbricks. Set only if Hub uses a separate DB.
# HUB_DATABASE_URL:
# Cube.js analytics for XM Suite v5. Enable the optional xm profile and set CUBEJS_API_SECRET to run Cube.
# Cube semantic-layer API used by Formbricks analytics. Required.
CUBEJS_API_URL: ${CUBEJS_API_URL:-http://cube:4000}
CUBEJS_API_SECRET: ${CUBEJS_API_SECRET:-}
CUBEJS_JWT_ISSUER: ${CUBEJS_JWT_ISSUER:-formbricks-web}
@@ -257,6 +257,8 @@ services:
condition: service_healthy
redis:
condition: service_healthy
cube:
condition: service_healthy
ports:
- 3000:3000
volumes:
@@ -294,9 +296,8 @@ services:
API_KEY: ${HUB_API_KEY:?HUB_API_KEY is required to run Hub}
DATABASE_URL: ${HUB_DATABASE_URL:-postgresql://postgres:postgres@postgres:5432/formbricks?sslmode=disable}
# Optional Cube.js analytics service for XM Suite v5. Enable with COMPOSE_PROFILES=xm and set CUBEJS_API_SECRET.
# Cube semantic-layer API used by Formbricks analytics dashboards.
cube:
profiles: ["xm"]
restart: always
image: cubejs/cube:v1.6.6
depends_on:
@@ -319,6 +320,12 @@ services:
volumes:
- ./cube/cube.js:/cube/conf/cube.js:ro
- ./cube/schema:/cube/conf/model:ro
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:4000/readyz"]
interval: 10s
timeout: 5s
retries: 12
start_period: 30s
volumes:
postgres:
-1
View File
@@ -527,7 +527,6 @@ EOT
hub_api_key=$(openssl rand -hex 32)
cubejs_api_secret=$(openssl rand -hex 32)
cat <<EOF > .env
COMPOSE_PROFILES=xm
HUB_API_KEY=$hub_api_key
CUBEJS_API_SECRET=$cubejs_api_secret
CUBEJS_JWT_ISSUER=formbricks-web
+855 -13
View File
@@ -1,12 +1,11 @@
# V3 API — Surveys (hand-maintained; not generated by generate-api-specs).
# Implementation: apps/web/app/api/v3/surveys/route.ts and apps/web/app/api/v3/surveys/[surveyId]/route.ts
# See apps/web/app/api/v3/README.md and docs/Survey-Server-Actions.md (Part III) for full context.
openapi: 3.1.0
info:
title: Formbricks API v3
description: |
**GET /api/v3/surveys** and **DELETE /api/v3/surveys/{surveyId}** — authenticate with **session cookie** or **`x-api-key`** (management key with access to the workspace).
**GET /api/v3/surveys**, **GET /api/v3/surveys/{surveyId}**, and **DELETE /api/v3/surveys/{surveyId}** — authenticate with **session cookie** or **`x-api-key`** (management key with access to the workspace).
**Spec location:** `docs/api-v3-reference/openapi.yml` (alongside v2 at `docs/api-v2-reference/openapi.yml`).
@@ -34,7 +33,7 @@ info:
The v3-backed survey overview page intentionally removes actions that are not yet exposed by this contract: `Created by` filtering, `Duplicate`, `Copy...`, `Preview`, and `Copy link`.
**Next steps (out of scope for this spec)**
Additional v3 survey endpoints, optional ETag/304, field selection — see Survey-Server-Actions.md Part III.
Additional v3 survey write endpoints, optional ETag/304, field selection, and survey version history.
version: 0.1.0
x-implementation-notes:
route: apps/web/app/api/v3/surveys/route.ts
@@ -198,6 +197,174 @@ paths:
- sessionAuth: []
- apiKeyAuth: []
/api/v3/surveys/{surveyId}:
get:
operationId: getSurveyV3
summary: Retrieve a survey
description: |
Returns the public v3 survey management resource for one survey. By default, translatable
fields are returned as canonical multilingual maps keyed by real locale codes. Use `lang`
to filter those maps to one or more requested locale codes.
tags:
- V3 Surveys
parameters:
- in: path
name: surveyId
required: true
schema:
type: string
format: cuid2
description: Survey identifier.
- in: query
name: lang
required: false
style: form
explode: false
schema:
type: array
items:
type: string
examples:
- [de-DE]
- [de-DE, pt-PT]
description: |
Comma-separated locale code filter for translatable fields, for example `?lang=de-DE,pt-PT`.
The response shape stays stable: translatable fields are always maps keyed by locale code, never
strings. The parser is case-insensitive, accepts `_` or `-` separators, and normalizes to canonical
BCP 47 casing (`de_DE`, `DE-de` → `de-DE`). A language-only selector (`de`) resolves to the matching
configured survey language when exactly one exists; otherwise it returns `400`. Disabled-but-configured
languages are readable in the management API so unfinished translations can be completed. Aliases are
not accepted.
responses:
"200":
description: Survey retrieved successfully
headers:
X-Request-Id:
schema: { type: string }
description: Request correlation ID
Cache-Control:
schema: { type: string }
example: "private, no-store"
content:
application/json:
schema:
type: object
required: [data]
properties:
data:
$ref: "#/components/schemas/SurveyResource"
examples:
canonical:
summary: Canonical multilingual authoring resource
value:
data:
id: clseedsurveycsat000000
workspaceId: clseedworkspace000000000
createdAt: "2026-05-18T09:24:54.014Z"
updatedAt: "2026-05-18T09:24:54.014Z"
name: CSAT Survey
type: link
status: inProgress
metadata: {}
defaultLanguage: en-US
languages:
- code: en-US
default: true
enabled: true
- code: de-DE
default: false
enabled: false
welcomeCard:
enabled: false
blocks:
- id: e0tfwzqk63op37y14z95qq3k
name: Main Block
elements:
- id: nzte4cm8836hgjw63pesziht
type: rating
range: 5
scale: smiley
headline:
en-US: How satisfied are you with our product?
de-DE: Wie zufrieden sind Sie mit unserem Produkt?
required: true
endings: []
hiddenFields:
enabled: false
variables: []
filtered:
summary: Language-filtered projection with ?lang=de-DE
value:
data:
id: clseedsurveycsat000000
workspaceId: clseedworkspace000000000
createdAt: "2026-05-18T09:24:54.014Z"
updatedAt: "2026-05-18T09:24:54.014Z"
name: CSAT Survey
type: link
status: inProgress
metadata: {}
defaultLanguage: en-US
languages:
- code: en-US
default: true
enabled: true
- code: de-DE
default: false
enabled: false
welcomeCard:
enabled: false
blocks:
- id: e0tfwzqk63op37y14z95qq3k
name: Main Block
elements:
- id: nzte4cm8836hgjw63pesziht
type: rating
range: 5
scale: smiley
headline:
de-DE: Wie zufrieden sind Sie mit unserem Produkt?
required: true
endings: []
hiddenFields:
enabled: false
variables: []
"400":
description: Invalid survey id, unsupported query parameter, unknown language, or unsupported legacy survey shape
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
"401":
description: Not authenticated (no valid session or API key)
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
"403":
description: Forbidden — no access, or survey does not exist (404 not used; avoids existence leak)
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
"429":
description: Rate limit exceeded
headers:
Retry-After:
schema: { type: integer }
description: Seconds until the current rate-limit window resets
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
"500":
description: Internal Server Error
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
security:
- sessionAuth: []
- apiKeyAuth: []
delete:
operationId: deleteSurveyV3
summary: Delete a survey
@@ -213,7 +380,7 @@ paths:
format: cuid2
description: Survey identifier.
responses:
"200":
"204":
description: Survey deleted successfully
headers:
X-Request-Id:
@@ -222,10 +389,6 @@ paths:
Cache-Control:
schema: { type: string }
example: "private, no-store"
content:
application/json:
schema:
$ref: "#/components/schemas/SurveyDeleteResponse"
"400":
description: Bad Request
content:
@@ -304,15 +467,694 @@ components:
properties:
enabled: { type: boolean }
isEncrypted: { type: boolean }
SurveyDeleteResponse:
TranslatableText:
allOf:
- $ref: "#/components/schemas/TranslatableTextMap"
description: |
Survey authoring text. `GET /api/v3/surveys/{surveyId}` always returns locale maps keyed by
real locale codes such as `en-US` and `de-DE`. Use `?lang=` to filter which locale keys are included.
The internal storage key `default` is never exposed by v3.
examples:
- en-US: What should we improve?
de-DE: Was sollten wir verbessern?
TranslatableTextMap:
type: object
required: [data]
description: Canonical multilingual text map keyed by real locale codes.
propertyNames:
type: string
description: BCP 47 locale code, for example `en-US`, `de-DE`, or `pt-BR`.
additionalProperties:
type: string
SurveyLanguage:
type: object
description: |
Language configured for this survey. Aliases/display names are intentionally not exposed in v3.
Disabled languages can still be read by the management API so unfinished translations can be completed.
required: [code, default, enabled]
properties:
data:
code:
type: string
description: Canonical locale code.
example: en-US
default:
type: boolean
description: Whether this is the default authoring language.
enabled:
type: boolean
description: Whether this language is enabled for respondent-facing delivery.
SurveyWelcomeCard:
type: object
description: Optional card shown before the first survey block.
required: [enabled]
properties:
enabled:
type: boolean
headline:
$ref: "#/components/schemas/TranslatableText"
subheader:
$ref: "#/components/schemas/TranslatableText"
buttonLabel:
$ref: "#/components/schemas/TranslatableText"
fileUrl:
type: string
videoUrl:
type: string
timeToFinish:
type: boolean
showResponseCount:
type: boolean
additionalProperties: true
SurveyHiddenFields:
type: object
description: |
Hidden fields, sometimes called embedded data in other survey products. Field ids are stable
public identifiers and may be referenced by logic, recall, quotas, integrations, and response data.
Use only letters, numbers, underscores, and hyphens; avoid spaces and reserved ids.
required: [enabled]
properties:
enabled:
type: boolean
fieldIds:
type: array
items:
type: string
pattern: "^[a-zA-Z0-9_-]+$"
uniqueItems: true
additionalProperties: false
SurveyVariable:
oneOf:
- $ref: "#/components/schemas/SurveyNumberVariable"
- $ref: "#/components/schemas/SurveyTextVariable"
description: |
Survey variable. Variable ids are stable references used by logic and calculation actions.
Variable names are human-readable labels and must be unique within the survey.
SurveyNumberVariable:
type: object
description: |
Number variable. Used by `calculate` logic actions with numeric operators such as `add`,
`subtract`, `multiply`, `divide`, or `assign`.
required: [id, name, type, value]
properties:
id:
type: string
format: cuid2
description: Stable variable id referenced from logic.
name:
type: string
pattern: "^[a-z0-9_]+$"
description: Unique variable name. Lowercase letters, numbers, and underscores only.
type:
type: string
enum: [number]
value:
type: number
description: Default numeric value.
additionalProperties: false
SurveyTextVariable:
type: object
description: |
Text variable. Used by `calculate` logic actions with text operators such as `assign` or `concat`.
required: [id, name, type, value]
properties:
id:
type: string
format: cuid2
description: Stable variable id referenced from logic.
name:
type: string
pattern: "^[a-z0-9_]+$"
description: Unique variable name. Lowercase letters, numbers, and underscores only.
type:
type: string
enum: [text]
value:
type: string
description: Default text value.
additionalProperties: false
SurveyEnding:
type: object
description: Ending reached after the last block or a jump action.
required: [id, type]
properties:
id:
type: string
format: cuid2
description: Stable ending id. `jumpToBlock.target` may point to this id.
type:
type: string
enum: [endScreen, redirectToUrl]
headline:
$ref: "#/components/schemas/TranslatableText"
subheader:
$ref: "#/components/schemas/TranslatableText"
buttonLabel:
$ref: "#/components/schemas/TranslatableText"
buttonLink:
type: string
imageUrl:
type: string
videoUrl:
type: string
url:
type: string
description: Redirect URL for `redirectToUrl` endings.
label:
type: string
description: Optional internal label for redirect endings.
additionalProperties: true
SurveyBlock:
type: object
description: |
Block-based survey section. Block ids are stable public identifiers. Logic and fallbacks can
jump to block ids or ending ids, so clients and agents should preserve ids unless intentionally
creating/deleting a block.
required: [id, name, elements]
properties:
id:
type: string
format: cuid2
description: Stable block id.
name:
type: string
minLength: 1
elements:
type: array
minItems: 1
items:
$ref: "#/components/schemas/SurveyElement"
logic:
type: array
items:
$ref: "#/components/schemas/SurveyBlockLogic"
logicFallback:
type: string
format: cuid2
description: Block or ending id used when no logic condition matches.
buttonLabel:
$ref: "#/components/schemas/TranslatableText"
backButtonLabel:
$ref: "#/components/schemas/TranslatableText"
additionalProperties: true
SurveyElement:
type: object
description: |
Survey element/question inside a block. Element ids are stable public identifiers used by
logic, recall strings, response data, quotas, integrations, and analysis. The schema lists
the fields used by all current element types; type-specific fields are present only when relevant.
required: [id, type, headline, required]
properties:
id:
type: string
pattern: "^[a-zA-Z0-9_-]+$"
description: Stable element id. Avoid spaces and reserved ids.
type:
type: string
enum:
- openText
- multipleChoiceSingle
- multipleChoiceMulti
- nps
- rating
- csat
- ces
- consent
- pictureSelection
- cta
- date
- fileUpload
- cal
- matrix
- address
- ranking
- contactInfo
headline:
$ref: "#/components/schemas/TranslatableText"
subheader:
$ref: "#/components/schemas/TranslatableText"
required:
type: boolean
imageUrl:
type: string
videoUrl:
type: string
isDraft:
type: boolean
description: Draft marker used by the editor and future update rules.
placeholder:
$ref: "#/components/schemas/TranslatableText"
longAnswer:
type: boolean
description: "`openText` only."
inputType:
type: string
enum: [text, email, url, number, phone]
description: "`openText` only."
charLimit:
type: object
required: [id]
description: "`openText` character limit configuration."
properties:
id: { type: string }
enabled:
type: boolean
min:
type: number
max:
type: number
additionalProperties: false
choices:
type: array
description: Choice list for multiple choice, ranking, and picture selection elements.
items:
oneOf:
- $ref: "#/components/schemas/SurveyChoice"
- $ref: "#/components/schemas/SurveyPictureChoice"
shuffleOption:
type: string
enum: [none, all, exceptLast, reverseOrderOccasionally, reverseOrderExceptLast]
displayType:
type: string
enum: [list, dropdown]
description: Multiple choice display style.
otherOptionPlaceholder:
$ref: "#/components/schemas/TranslatableText"
lowerLabel:
$ref: "#/components/schemas/TranslatableText"
upperLabel:
$ref: "#/components/schemas/TranslatableText"
isColorCodingEnabled:
type: boolean
scale:
type: string
enum: [number, smiley, star]
description: Rating, CSAT, CES, or NPS scale display.
range:
type: integer
enum: [3, 4, 5, 6, 7, 10]
description: Rating range. CSAT is always 5; CES is 5 or 7.
label:
$ref: "#/components/schemas/TranslatableText"
description: Consent checkbox label.
allowMulti:
type: boolean
description: "`pictureSelection` only."
buttonExternal:
type: boolean
description: "`cta` only."
buttonUrl:
type: string
description: "`cta` only."
ctaButtonLabel:
$ref: "#/components/schemas/TranslatableText"
html:
$ref: "#/components/schemas/TranslatableText"
description: "`date` helper copy."
format:
type: string
enum: [M-d-y, d-M-y, y-M-d]
description: "`date` only."
allowMultipleFiles:
type: boolean
description: "`fileUpload` only."
maxSizeInMB:
type: number
description: "`fileUpload` only."
allowedFileExtensions:
type: array
items:
type: string
description: "`fileUpload` only."
calUserName:
type: string
description: "`cal` only."
calHost:
type: string
description: "`cal` only."
rows:
type: array
description: Matrix rows.
items:
$ref: "#/components/schemas/SurveyChoice"
columns:
type: array
description: Matrix columns.
items:
$ref: "#/components/schemas/SurveyChoice"
addressLine1:
$ref: "#/components/schemas/SurveyToggleInputConfig"
addressLine2:
$ref: "#/components/schemas/SurveyToggleInputConfig"
city:
$ref: "#/components/schemas/SurveyToggleInputConfig"
state:
$ref: "#/components/schemas/SurveyToggleInputConfig"
zip:
$ref: "#/components/schemas/SurveyToggleInputConfig"
country:
$ref: "#/components/schemas/SurveyToggleInputConfig"
firstName:
$ref: "#/components/schemas/SurveyToggleInputConfig"
lastName:
$ref: "#/components/schemas/SurveyToggleInputConfig"
email:
$ref: "#/components/schemas/SurveyToggleInputConfig"
phone:
$ref: "#/components/schemas/SurveyToggleInputConfig"
company:
$ref: "#/components/schemas/SurveyToggleInputConfig"
validation:
$ref: "#/components/schemas/SurveyValidation"
additionalProperties: true
SurveyChoice:
type: object
required: [id, label]
properties:
id:
type: string
description: Stable choice id.
label:
$ref: "#/components/schemas/TranslatableText"
additionalProperties: false
SurveyPictureChoice:
type: object
required: [id, imageUrl]
properties:
id:
type: string
description: Stable picture choice id.
imageUrl:
type: string
additionalProperties: false
SurveyToggleInputConfig:
type: object
description: Field config for address and contact info elements.
required: [show, required, placeholder]
properties:
show:
type: boolean
required:
type: boolean
placeholder:
$ref: "#/components/schemas/TranslatableText"
additionalProperties: false
SurveyValidation:
type: object
description: Optional element-level validation rules.
required: [rules]
properties:
logic:
type: string
enum: [and, or]
default: and
rules:
type: array
items:
$ref: "#/components/schemas/SurveyValidationRule"
additionalProperties: false
SurveyValidationRule:
type: object
required: [id, type, params]
properties:
id:
type: string
type:
type: string
enum:
- minLength
- maxLength
- pattern
- email
- url
- phone
- equals
- doesNotEqual
- contains
- doesNotContain
- minValue
- maxValue
- isGreaterThan
- isLessThan
- minSelections
- maxSelections
- minRanked
- rankAll
- minRowsAnswered
- answerAllRows
- isLaterThan
- isEarlierThan
- isBetween
- isNotBetween
- fileExtensionIs
- fileExtensionIsNot
params:
type: object
additionalProperties: true
field:
type: string
enum:
[
addressLine1,
addressLine2,
city,
state,
zip,
country,
firstName,
lastName,
email,
phone,
company,
]
additionalProperties: false
SurveyBlockLogic:
type: object
description: Conditional logic rule evaluated at block level.
required: [id, conditions, actions]
properties:
id:
type: string
format: cuid2
conditions:
$ref: "#/components/schemas/SurveyConditionGroup"
actions:
type: array
items:
$ref: "#/components/schemas/SurveyLogicAction"
additionalProperties: false
SurveyConditionGroup:
type: object
required: [id, connector, conditions]
properties:
id:
type: string
format: cuid2
connector:
type: string
enum: [and, or]
conditions:
type: array
items:
oneOf:
- $ref: "#/components/schemas/SurveyCondition"
- $ref: "#/components/schemas/SurveyConditionGroup"
additionalProperties: false
SurveyCondition:
type: object
description: |
Single condition. Operators such as `isSubmitted`, `isSkipped`, `isClicked`, `isAccepted`,
`isBooked`, `isSet`, and `isEmpty` do not use `rightOperand`; comparison operators do.
required: [id, leftOperand, operator]
properties:
id:
type: string
format: cuid2
leftOperand:
$ref: "#/components/schemas/SurveyDynamicReference"
operator:
type: string
enum:
- equals
- doesNotEqual
- contains
- doesNotContain
- startsWith
- doesNotStartWith
- endsWith
- doesNotEndWith
- isSubmitted
- isSkipped
- isGreaterThan
- isLessThan
- isGreaterThanOrEqual
- isLessThanOrEqual
- equalsOneOf
- includesAllOf
- includesOneOf
- doesNotIncludeOneOf
- doesNotIncludeAllOf
- isClicked
- isNotClicked
- isAccepted
- isBefore
- isAfter
- isBooked
- isPartiallySubmitted
- isCompletelySubmitted
- isSet
- isNotSet
- isEmpty
- isNotEmpty
- isAnyOf
rightOperand:
$ref: "#/components/schemas/SurveyLogicOperand"
additionalProperties: false
SurveyLogicOperand:
oneOf:
- type: object
required: [type, value]
properties:
type:
type: string
enum: [static]
value:
oneOf:
- type: string
- type: number
- type: array
items:
type: string
additionalProperties: false
- $ref: "#/components/schemas/SurveyDynamicReference"
SurveyDynamicReference:
type: object
description: Dynamic reference to another value in the survey document.
required: [type, value]
properties:
type:
type: string
enum: [element, variable, hiddenField]
value:
type: string
description: Element id, variable id, or hidden field id depending on `type`.
meta:
type: object
additionalProperties:
type: string
additionalProperties: false
SurveyLogicAction:
oneOf:
- $ref: "#/components/schemas/SurveyCalculateAction"
- $ref: "#/components/schemas/SurveyRequireAnswerAction"
- $ref: "#/components/schemas/SurveyJumpToBlockAction"
description: |
Logic action. Keep referenced ids stable: `calculate.variableId` points to a variable id,
`requireAnswer.target` points to an element id, and `jumpToBlock.target` points to a block id
or ending id.
SurveyCalculateAction:
type: object
description: Updates a survey variable when the logic rule matches.
required: [id, objective, variableId, operator, value]
properties:
id:
type: string
format: cuid2
objective:
type: string
enum: [calculate]
variableId:
type: string
format: cuid2
description: Variable id for `calculate`.
operator:
type: string
enum: [assign, concat, add, subtract, multiply, divide]
value:
$ref: "#/components/schemas/SurveyLogicOperand"
additionalProperties: false
SurveyRequireAnswerAction:
type: object
description: Requires an element/question to be answered before continuing.
required: [id, objective, target]
properties:
id:
type: string
format: cuid2
objective:
type: string
enum: [requireAnswer]
target:
type: string
description: Target element id.
additionalProperties: false
SurveyJumpToBlockAction:
type: object
description: Jumps to another block or ending when the logic rule matches.
required: [id, objective, target]
properties:
id:
type: string
format: cuid2
objective:
type: string
enum: [jumpToBlock]
target:
type: string
format: cuid2
description: Target block id or ending id.
additionalProperties: false
SurveyResource:
type: object
required:
- id
- workspaceId
- createdAt
- updatedAt
- name
- type
- status
- metadata
- defaultLanguage
- languages
- welcomeCard
- blocks
- endings
- hiddenFields
- variables
properties:
id: { type: string }
workspaceId: { type: string }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
name: { type: string }
type: { type: string, enum: [link, app, website, web] }
status:
type: string
enum: [draft, inProgress, paused, completed]
metadata:
type: object
nullable: true
additionalProperties: true
defaultLanguage:
type: string
description: Real locale code for the survey default language. The internal `default` translation key is never exposed.
languages:
type: array
items:
$ref: "#/components/schemas/SurveyLanguage"
welcomeCard:
$ref: "#/components/schemas/SurveyWelcomeCard"
blocks:
type: array
items:
$ref: "#/components/schemas/SurveyBlock"
endings:
type: array
items:
$ref: "#/components/schemas/SurveyEnding"
hiddenFields:
$ref: "#/components/schemas/SurveyHiddenFields"
variables:
type: array
items:
$ref: "#/components/schemas/SurveyVariable"
Problem:
type: object
description: RFC 9457 Problem Details for HTTP APIs (`application/problem+json`). Responses typically include a machine-readable `code` field alongside `title`, `status`, `detail`, and `requestId`.
+1
View File
@@ -118,6 +118,7 @@
"xm-and-surveys/surveys/website-app-surveys/workspace-id-migration",
"xm-and-surveys/surveys/website-app-surveys/framework-guides",
"xm-and-surveys/surveys/website-app-surveys/google-tag-manager",
"xm-and-surveys/surveys/website-app-surveys/custom-css",
{
"group": "Features",
"icon": "wrench",
+29 -25
View File
@@ -12,11 +12,12 @@ deployment, review this section before starting the new version.
### What Changes In v5
- **Formbricks Hub is now mandatory** for self-hosted Formbricks v5 deployments.
- **Cube is now part of the baseline stack** alongside Hub. Docker, one-click, and Helm all bundle Cube by
default; `CUBEJS_API_SECRET` is required. Operators can disable the bundled Cube deployment in Helm to use
an external cluster instead.
- **Edge rate limiting is now required** for specific public and API-key routes. Those routes are no longer
throttled inside the application server.
- **AI features are configured at the instance level** via `AI_*` environment variables.
- **XM Suite v5 analytics depends on Cube.js**. The Docker and one-click stack bundle it, while Helm
deployments still need a separate reachable Cube.js instance and `CUBEJS_API_SECRET`.
<Warning>
Formbricks v5 removes application-level rate limiting for several routes that are now expected to be
@@ -32,7 +33,8 @@ Before you restart your instance on Formbricks v5:
- identify your current deployment type: one-click, manual Docker Compose, or Kubernetes/Helm
- confirm Redis/Valkey and your file storage setup are already healthy from your v4 baseline
- identify whether file uploads use external S3-compatible storage or a legacy bundled MinIO service
- decide whether this instance needs AI features, dashboards/analysis, or only core survey flows
- budget approximately ~500 MB additional RAM headroom for the bundled Cube container (dashboards and analysis are part of the baseline now)
- decide whether this instance needs optional AI features
- verify whether you already run Envoy Gateway or another equivalent edge rate limiter for the covered routes
### Required Config And Infrastructure Changes
@@ -75,13 +77,15 @@ enterprise functionality.
- `AI_PROVIDER=azure` requires `AI_AZURE_API_KEY` and either `AI_AZURE_BASE_URL` or
`AI_AZURE_RESOURCE_NAME`
#### Cube.js Analytics
#### Cube
XM Suite v5 dashboard and analysis features require Cube.js.
Cube is part of the baseline Formbricks v5 stack.
- the Docker and one-click stack bundle the `cube` service and expect `CUBEJS_API_SECRET`
- Helm deployments still need a separate reachable Cube.js instance
- the Formbricks app expects `CUBEJS_API_URL` and `CUBEJS_API_SECRET`
- the Docker, one-click, and Helm deployments all bundle the `cube` service by default
- the Formbricks app requires `CUBEJS_API_URL` and `CUBEJS_API_SECRET`; the install/dev-setup scripts
generate the secret automatically for new installs
- Helm operators who want to run an external Cube cluster can set `cube.enabled: false` and provide their
own endpoint via `deployment.env.CUBEJS_API_URL`
- if you run Cube yourself, you may also need to override `CUBEJS_DB_*` values for the Cube service
### Upgrade Steps By Deployment Type
@@ -142,15 +146,15 @@ XM Suite v5 dashboard and analysis features require Cube.js.
- add a non-empty `HUB_API_KEY` and reuse the same value wherever your deployment resolves Hub auth
- keep `HUB_API_URL` at `http://hub:8080` unless Hub runs elsewhere
- include the bundled `hub-migrate` and `hub` services
- if you use the bundled XM Suite v5 analytics stack, sync `formbricks/cube/cube.js` and
`formbricks/cube/schema/FeedbackRecords.js` from the current release and ensure
`formbricks/.env` contains `CUBEJS_API_SECRET`
- sync `formbricks/cube/cube.js` and `formbricks/cube/schema/FeedbackRecords.js` from the current
release and ensure `formbricks/.env` contains `CUBEJS_API_SECRET` (Cube is part of the baseline stack
in v5)
- if your older setup still uses bundled MinIO for uploads, review that storage path separately before the
first v5 restart; newer self-hosting updates move the bundled object-storage path to RustFS, while
external S3-compatible storage keeps the same `S3_*` app contract
- add any `AI_*` variables you need
- if you do not run the bundled Docker analytics path, point `CUBEJS_API_URL` at your external Cube.js
instance and provide the matching `CUBEJS_API_SECRET`
- if you prefer to run an external Cube instance, point `CUBEJS_API_URL` at it and provide the matching
`CUBEJS_API_SECRET`; otherwise the bundled `cube` service runs against the local Postgres
After the compose file is updated and your edge rate limiter is in place:
@@ -171,15 +175,14 @@ XM Suite v5 dashboard and analysis features require Cube.js.
- `HUB_API_KEY` is configured and the same value is available wherever your deployment resolves Hub auth
- `HUB_API_URL` points to the Hub service the app can reach
- the compose stack includes `hub-migrate` and `hub`
- the XM Suite v5 Docker stack also includes `cube`, `cube/cube.js`, and
`cube/schema/FeedbackRecords.js`, with `CUBEJS_API_SECRET` available through your `.env` or shell
environment
- the v5 stack also includes `cube`, `cube/cube.js`, and `cube/schema/FeedbackRecords.js`, with
`CUBEJS_API_SECRET` available through your `.env` or shell environment
- if your legacy Compose file still includes bundled MinIO for uploads, treat that as a separate storage
review when comparing files; newer bundled storage guidance uses RustFS, while external S3-compatible
storage keeps the same `S3_*` app contract
- any `AI_*` variables you need are set
- if you override the bundled analytics path, point `CUBEJS_API_URL` at your external Cube.js instance and
supply the matching `CUBEJS_API_SECRET`
- if you prefer to run an external Cube instance, point `CUBEJS_API_URL` at it and supply the matching
`CUBEJS_API_SECRET`; otherwise the bundled `cube` service runs against the local Postgres
Then restart the stack:
@@ -190,8 +193,8 @@ XM Suite v5 dashboard and analysis features require Cube.js.
```
<Info>
The XM Suite v5 Docker Compose stack bundles Hub and Cube.js. Keep the bundled `cube/` config files in
sync with `docker-compose.yml` when you update this path.
The v5 Docker Compose stack bundles Hub and Cube. Keep the bundled `cube/` config files in sync with
`docker-compose.yml` when you update this path.
</Info>
</Tab>
<Tab title="Kubernetes">
@@ -211,8 +214,8 @@ XM Suite v5 dashboard and analysis features require Cube.js.
- `envoy.controller.enabled=false` when the cluster already has a compatible Envoy Gateway controller
- if you use bundled Envoy rate limiting, enable a dedicated backend with `envoyRedis.enabled=true`
- if you already have an equivalent edge rate limiter outside the chart, keep that protection in place
- if the instance needs XM Suite v5 analytics or dashboards, provide `CUBEJS_API_URL` and
`CUBEJS_API_SECRET` for the external Cube.js deployment
- `CUBEJS_API_SECRET` is provided (Cube is bundled by default at `cube.enabled: true`; set to `false`
and point `CUBEJS_API_URL` at your own endpoint if you prefer an external Cube cluster)
</Tab>
</Tabs>
@@ -225,8 +228,7 @@ After the upgrade:
- verify any Hub-backed connector or feedback flows you use
- verify covered routes are rate-limited at the edge layer
- verify AI features only if you configured the required `AI_*` variables
- verify dashboards and analysis flows only if your deployment path includes Cube.js or points to an external
Cube.js instance
- verify dashboards and analysis flows against the bundled (or external) Cube endpoint
### Troubleshooting And Rollback
@@ -238,7 +240,9 @@ Common upgrade issues:
protected by the legacy in-app limiter
- **Missing AI credentials**: AI features remain unavailable until `AI_PROVIDER`, `AI_MODEL`, and the matching
provider credentials are set correctly
- **Cube not configured**: dashboards or analysis queries fail even though the core Formbricks app is healthy
- **Missing `CUBEJS_API_SECRET`** (or unreachable Cube endpoint): the Formbricks app fails env validation
at boot, or — if env vars are present but Cube is unreachable — dashboards and analysis queries fail
while the rest of the app stays healthy
If you need to roll back:
@@ -16,25 +16,16 @@ Integrating Google OAuth with your Formbricks instance allows users to log in us
- A Formbricks instance running
### Account deletion reauthentication
### Account Deletion SSO Confirmation
For SSO-only users, Formbricks requires a fresh Google `auth_time` claim before deleting the account. Google only returns this claim when your OAuth app is published, verified, and has **Session age claims** enabled in Google Auth Platform.
For SSO-only users, Formbricks asks the user to type their email address and then redirects them through Google OAuth before deleting the account. Formbricks asks Google for an interactive login prompt, and the deletion continues only when Google returns the same linked provider account.
To enable it, open Google Auth Platform, select your app project, go to **Settings**, and under **Advanced Settings** enable **Session age claims**. Then set:
```sh
GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED=1
```
If this Google setting and environment variable are not enabled together, Google login can still work, but SSO-only account deletion will fail closed.
Google does not support app-triggered Google Account reauthentication requests. If the returned `auth_time` is too old, the deletion flow is rejected and the user must complete Google sign-in from a fresh Google session before trying again.
This confirms the Google identity for the current deletion attempt, but it does not validate a provider-side freshness proof. Google still controls whether it asks for a password or MFA.
<Warning>
If you need to allow SSO-only users to delete their accounts without a fresh SSO reauthentication check, set
`DISABLE_ACCOUNT_DELETION_SSO_REAUTH=1`. This bypasses the deletion reauthentication marker for passwordless
SSO accounts, so users can delete their account with email confirmation only. Keep it unset unless you
accept this security trade-off.
If you set `DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION=1`, Formbricks skips this SSO identity confirmation
redirect for passwordless SSO accounts. Users can delete their account with only the in-app email text
confirmation. Keep it unset unless you accept this security trade-off.
</Warning>
### How to connect your Formbricks instance to Google
@@ -77,10 +68,8 @@ Google does not support app-triggered Google Account reauthentication requests.
```sh
GOOGLE_CLIENT_ID=your-client-id-here
GOOGLE_CLIENT_SECRET=your-client-secret-here
# Optional: only when Google Auth Platform Session age claims are enabled.
GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED=1
# Optional: dangerous fallback that disables fresh SSO reauthentication for account deletion.
# DISABLE_ACCOUNT_DELETION_SSO_REAUTH=1
# Optional: dangerous fallback that skips SSO identity confirmation for account deletion.
# DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION=1
```
- Alternatively, you can add the environment variables directly to the running container using the following commands (replace `container_id` with your actual Docker container ID):
@@ -42,7 +42,7 @@ For `AI_PROVIDER=google`, use a Gemini model ID such as `gemini-2.5-flash` toget
| PASSWORD_RESET_DISABLED | Disables password reset functionality if set to 1. | optional | |
| PASSWORD_RESET_TOKEN_LIFETIME_MINUTES | Configures how long password reset links remain valid in minutes. Accepted values are integers from 5 to 120. | optional | 30 |
| EMAIL_VERIFICATION_DISABLED | Disables email verification if set to 1. | optional | |
| DISABLE_ACCOUNT_DELETION_SSO_REAUTH | Disables fresh SSO reauthentication for passwordless SSO account deletion if set to 1. Users can delete their account with email confirmation only. Keep unset unless you accept this security trade-off. | optional | |
| DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION | Skips the SSO identity confirmation redirect for passwordless SSO account deletion if set to 1. Users can delete SSO accounts with only the in-app email text confirmation. Keep unset unless you accept this security trade-off. | optional | |
| RATE_LIMITING_DISABLED | Disables only the application-level rate limiter if set to 1. It does not disable Envoy or an equivalent edge rate limiter. | optional | |
| TELEMETRY_DISABLED | Disables telemetry reporting if set to 1. Ignored when an Enterprise License is active. | optional | |
| DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS | Allows webhook URLs to point to internal/private network addresses (e.g. localhost, 192.168.x.x) if set to 1. Useful for self-hosted instances that need to send webhooks to internal services. | optional | |
@@ -64,7 +64,6 @@ For `AI_PROVIDER=google`, use a Gemini model ID such as `gemini-2.5-flash` toget
| GITHUB_SECRET | Secret for GitHub. | optional (required if GitHub auth is enabled) | |
| GOOGLE_CLIENT_ID | Client ID for Google. | optional (required if Google auth is enabled) | |
| GOOGLE_CLIENT_SECRET | Secret for Google. | optional (required if Google auth is enabled) | |
| GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED | Enables Google `auth_time` validation for SSO-only account deletion if set to 1. Only enable after Google Auth Platform Session age claims are enabled for the OAuth app. | optional | |
| AI_PROVIDER | Instance-level AI provider used in the background. Supported values: `aws`, `google`, `azure`. | optional (required if AI is enabled) | |
| AI_MODEL | Instance-level AI model or deployment name used by the active provider. | optional (required if `AI_PROVIDER` is set) | |
| AI_GOOGLE_CLOUD_PROJECT | Google Cloud project ID for the `google` AI provider. | optional (required if `AI_PROVIDER=google`) | |
@@ -120,30 +119,30 @@ bundled Docker Compose or Helm assets, the following variables apply:
| HUB_API_URL | Base URL the Formbricks app uses to call Hub. With the bundled Docker stack, keep this at `http://hub:8080` unless Hub runs elsewhere. | required | `http://hub:8080` (bundled Docker), `http://localhost:8080` (local dev) |
| HUB_DATABASE_URL | PostgreSQL connection URL for Hub. Omit to use the same database as Formbricks. | optional | Same as Formbricks `DATABASE_URL` (shared database) |
#### Cube.js Analytics for XM Suite v5
#### Cube Analytics
XM Suite v5 dashboard and analysis features require a reachable Cube.js instance. Formbricks generates the backend
Cube is part of the baseline Formbricks v5 stack and is required. Formbricks generates the backend
Cube JWT from `CUBEJS_API_SECRET`, so `CUBEJS_API_TOKEN` is not part of the supported setup contract.
If you do not use XM Suite v5 analytics, omit the Cube variables and leave the bundled Docker `xm` profile disabled.
| Variable | Description | Required | Default |
| ------------------------- | ------------------------------------------------------------------------------------------------------ | ---------------------------------- | ------------------------------------ |
| CUBEJS_API_URL | Base URL the Formbricks app uses to call Cube. Use `http://localhost:4000` locally. | required for XM Suite v5 analytics | `http://localhost:4000` in local dev |
| CUBEJS_API_SECRET | Shared secret Formbricks uses to sign Cube API JWTs. Generate with `openssl rand -hex 32`. | required for XM Suite v5 analytics | |
| CUBEJS_JWT_ISSUER | JWT issuer expected by Cube and used by Formbricks when signing per-request Cube tokens. | optional | `formbricks-web` |
| CUBEJS_JWT_AUDIENCE | JWT audience expected by Cube and used by Formbricks when signing per-request Cube tokens. | optional | `formbricks-cube` |
| CUBEJS_DB_HOST | Database host for the Cube service. Only needed when you run Cube yourself and override defaults. | optional | Depends on your Cube deployment |
| CUBEJS_DB_PORT | Database port for the Cube service. Only needed when you run Cube yourself and override defaults. | optional | Depends on your Cube deployment |
| CUBEJS_DB_NAME | Database name for the Cube service. Only needed when you run Cube yourself and override defaults. | optional | Depends on your Cube deployment |
| CUBEJS_DB_USER | Database user for the Cube service. Only needed when you run Cube yourself and override defaults. | optional | Depends on your Cube deployment |
| CUBEJS_DB_PASS | Database password for the Cube service. Only needed when you run Cube yourself and override defaults. | optional | Depends on your Cube deployment |
| Variable | Description | Required | Default |
| ------------------------- | ------------------------------------------------------------------------------------------------------ | -------- | ------------------------------------ |
| CUBEJS_API_URL | Base URL the Formbricks app uses to call Cube. Local dev (app on host): `http://localhost:4000`. Docker/container: `http://cube:4000` (service name). | required | |
| CUBEJS_API_SECRET | Shared secret Formbricks uses to sign Cube API JWTs. Generate with `openssl rand -hex 32`. | required | |
| CUBEJS_JWT_ISSUER | JWT issuer expected by Cube and used by Formbricks when signing per-request Cube tokens. | optional | `formbricks-web` |
| CUBEJS_JWT_AUDIENCE | JWT audience expected by Cube and used by Formbricks when signing per-request Cube tokens. | optional | `formbricks-cube` |
| CUBEJS_DB_HOST | Database host for the Cube service. Only needed when you run Cube yourself and override defaults. | optional | Depends on your Cube deployment |
| CUBEJS_DB_PORT | Database port for the Cube service. Only needed when you run Cube yourself and override defaults. | optional | Depends on your Cube deployment |
| CUBEJS_DB_NAME | Database name for the Cube service. Only needed when you run Cube yourself and override defaults. | optional | Depends on your Cube deployment |
| CUBEJS_DB_USER | Database user for the Cube service. Only needed when you run Cube yourself and override defaults. | optional | Depends on your Cube deployment |
| CUBEJS_DB_PASS | Database password for the Cube service. Only needed when you run Cube yourself and override defaults. | optional | Depends on your Cube deployment |
The bundled Docker Compose Cube service sets `CUBEJS_DEFAULT_API_SCOPES=meta,data` directly on the Cube
container. If you run Cube outside the bundled Compose stack, configure the equivalent Cube service environment
there rather than adding it to the Formbricks app environment.
For Helm deployments, Formbricks does not deploy Cube for you in this chart. Provide an external Cube endpoint with
`CUBEJS_API_URL` and supply `CUBEJS_API_SECRET` through your existing secret management setup.
For Helm deployments, the chart deploys Cube by default (`cube.enabled: true`). To use an external Cube
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 -->
+10 -14
View File
@@ -16,8 +16,8 @@ Make sure Docker and Docker Compose are installed on your system. These are usua
</Note>
<Info>
Starting with Formbricks v5, the production Docker Compose stack includes Formbricks Hub and the XM Suite v5
Cube.js services. Generate `HUB_API_KEY` and `CUBEJS_API_SECRET` during setup, keep `HUB_API_URL` at its
Starting with Formbricks v5, the production Docker Compose stack includes Formbricks Hub and Cube as part of
the baseline. Generate `HUB_API_KEY` and `CUBEJS_API_SECRET` during setup, keep `HUB_API_URL` at its
internal default unless Hub runs elsewhere, and use the [migration guide](/self-hosting/advanced/migration#v5)
when upgrading an existing 4.x instance.
</Info>
@@ -34,7 +34,7 @@ Make sure Docker and Docker Compose are installed on your system. These are usua
1. **Download the Docker Files**
Get the Docker Compose file plus the Cube.js configuration shipped with the XM Suite v5 stack:
Get the Docker Compose file plus the Cube configuration shipped with the baseline stack:
```bash
mkdir -p cube/schema
@@ -43,15 +43,12 @@ Make sure Docker and Docker Compose are installed on your system. These are usua
curl -o cube/schema/FeedbackRecords.js https://raw.githubusercontent.com/formbricks/formbricks/stable/docker/cube/schema/FeedbackRecords.js
```
1. **Generate Hub Secret and Optional Cube Secret**
1. **Generate Hub and Cube Secrets**
Formbricks Hub requires an API key. XM Suite v5 analytics also requires Cube.js; set the optional `xm`
Compose profile and Cube secret when you want to run the bundled Cube service. For a Hub-only stack, create
`.env` with just `HUB_API_KEY` and omit `COMPOSE_PROFILES` and `CUBEJS_API_SECRET`.
Formbricks Hub and Cube each require a shared secret. Create `.env` with both:
```bash
cat <<EOF > .env
COMPOSE_PROFILES=xm
HUB_API_KEY=$(openssl rand -hex 32)
CUBEJS_API_SECRET=$(openssl rand -hex 32)
CUBEJS_JWT_ISSUER=formbricks-web
@@ -133,8 +130,7 @@ Make sure Docker and Docker Compose are installed on your system. These are usua
1. **Start the Docker Setup**
Now, you're ready to run Formbricks with Docker. Use the command below to start Formbricks together with
PostgreSQL, Redis, and Formbricks Hub. If the `xm` profile is set in `.env`, Docker Compose also starts Cube.js
for XM Suite v5 analytics.
PostgreSQL, Redis, Formbricks Hub, and Cube.
```bash
docker compose up -d
@@ -147,8 +143,8 @@ Make sure Docker and Docker Compose are installed on your system. These are usua
Once the setup is running, open [**http://localhost:3000**](http://localhost:3000) in your browser to access Formbricks. The first time you visit, you'll see a setup wizard. Follow the steps to create your first user and start using Formbricks.
<Note>
The bundled Docker stack keeps Formbricks Hub internal to the compose network. When the `xm` profile is
enabled, Cube.js is internal too. The app reaches them through `http://hub:8080` and `http://cube:4000`.
The bundled Docker stack keeps Formbricks Hub and Cube internal to the compose network. The app reaches
them through `http://hub:8080` and `http://cube:4000`.
</Note>
<Info>
@@ -164,8 +160,8 @@ Please take a look at our [migration guide](/self-hosting/advanced/migration) fo
<Info>
For a major migration such as Formbricks 4.x to 5.0, update your compose structure and configuration first.
Pulling images alone is not enough if your stack does not yet include Hub, `HUB_API_KEY`, the bundled
`cube/` config files plus `CUBEJS_API_SECRET`, or the new edge rate-limiting setup.
Pulling images alone is not enough if your stack does not yet include Hub (`HUB_API_KEY`), Cube (`cube/`
config files plus `CUBEJS_API_SECRET`), or the new edge rate-limiting setup.
</Info>
1. Pull the latest Formbricks image
+8 -6
View File
@@ -109,13 +109,14 @@ envoyRedis:
This keeps Envoy rate-limiting state separate from the application's own Redis traffic.
### Cube Is Optional
### Cube
Cube is only needed for analytics dashboards or other analysis flows that depend on Cube queries.
Cube is part of the baseline Formbricks v5 stack and is bundled with the chart by default
(`cube.enabled: true`). To run an external Cube cluster instead:
- deploy Cube separately when you need it
- configure `CUBEJS_API_URL` and `CUBEJS_API_SECRET` for the Formbricks app
- do not expect the main Formbricks chart to provision Cube automatically
- set `cube.enabled: false` to skip the bundled Cube deployment
- point the app at your external endpoint via `deployment.env.CUBEJS_API_URL`
- supply `CUBEJS_API_SECRET` via `deployment.env` or `deployment.envFrom`
## 4. Upgrade The Deployment
@@ -133,7 +134,8 @@ For a Formbricks 4.x to 5.0 migration, confirm the following before running the
- `HUB_API_KEY` is present
- your edge rate-limiting plan is in place
- any required `AI_*` variables are added
- Cube is configured only if this instance needs analytics dashboards or analysis queries
- `CUBEJS_API_SECRET` is configured (Cube is bundled by default; provide an external endpoint if you set
`cube.enabled: false`)
## 5. Key Values
+2 -2
View File
@@ -53,8 +53,8 @@ curl -fsSL https://raw.githubusercontent.com/formbricks/formbricks/stable/docker
```
<Info>
The current v5 one-click stack is based on the production Docker Compose file and includes Formbricks Hub plus
the bundled XM Suite v5 Cube.js files under `formbricks/cube/`. Ensure your generated
The current v5 one-click stack is based on the production Docker Compose file and includes Formbricks Hub
and Cube as part of the baseline (Cube configuration lives under `formbricks/cube/`). Ensure your generated
`formbricks/docker-compose.yml` contains a non-empty `HUB_API_KEY` and that `formbricks/.env` contains
`CUBEJS_API_SECRET` before treating the v5 stack as ready. If either value is missing after the script
finishes, add it manually. `HUB_API_URL` should normally stay at `http://hub:8080`.
@@ -87,14 +87,14 @@ Action is of the following types:
![Action Require](/images/xm-and-surveys/surveys/general-features/conditional-logic/action-require.webp)
* **Jump to Question**: Skip to a specific question. The user will be redirected to the specified question based on the condition.
* **Jump to Block**: Skip to a specific block. The user will be redirected to the specified block based on the condition.
![Action Jump](/images/xm-and-surveys/surveys/general-features/conditional-logic/action-jump.webp)
* **Save Logic**: Click the `Save` button to save the logic block.
## Question Logic
## Block Logic
This logic is executed when the user answers the question. Logic can be as simple as showing a follow-up question based on the answer or as complex as calculating a score based on multiple answers.
This logic is executed when the user reaches the block. Logic can be as simple as showing a follow-up block based on earlier answers or as complex as calculating a score based on multiple answers.
![Question Logic](/images/xm-and-surveys/surveys/general-features/conditional-logic/question-logic.webp)
![Block Logic](/images/xm-and-surveys/surveys/general-features/conditional-logic/question-logic.webp)
@@ -0,0 +1,145 @@
---
title: "Custom CSS"
description: "Use scoped global CSS to customize Website & App Surveys without breaking your app styles."
icon: "palette"
---
Yes, custom CSS for Website & App Surveys is supported.
Use this when the Styling UI is not enough and you need tighter brand control.
<Note>
Start with the built-in [Styling Theme](/xm-and-surveys/core-features/styling-theme) first. Use custom CSS only for advanced overrides.
</Note>
## Problem
Website & App Surveys are rendered inside your product, so your app's global CSS can compete with survey styles.
Common examples:
- A global rule like `button { ... !important; }` changes survey buttons.
- Typography resets change survey text spacing and sizing.
- Utility-heavy global styles create unexpected visual differences between pages.
## Solution
Scope your overrides to the survey root (`#fbjs`) and use `!important` for the exact targets you want to control.
This gives you two important guarantees:
1. Your custom rules apply only to the survey.
2. Your rules reliably win against conflicting global styles.
## Add Scoped Overrides In Global CSS
<Steps>
<Step title="Open your global stylesheet">
Use the stylesheet that is loaded on pages where surveys are shown (for example `globals.css`).
</Step>
<Step title="Add scoped rules under #fbjs">
Keep all custom rules prefixed with `#fbjs`.
</Step>
<Step title="Use !important only where needed">
Add `!important` to properties that are still being overridden by your app-wide CSS.
</Step>
</Steps>
```css globals.css
/* 1) Theme-level variables */
#fbjs {
--fb-brand-color: #0ea5e9 !important;
--fb-brand-text-color: #ffffff !important;
--fb-heading-color: #0f172a !important;
--fb-subheading-color: #334155 !important;
--fb-survey-background-color: #ffffff !important;
--fb-border-radius: 12px !important;
}
/* 2) Targeted component overrides */
#fbjs .button-custom,
#fbjs button.button-custom {
border: 1px solid #0284c7 !important;
box-shadow: none !important;
}
#fbjs .label-headline,
#fbjs .label-headline * {
font-size: 1.125rem !important;
font-weight: 700 !important;
}
#fbjs .bg-input-bg,
#fbjs .border-input-border,
#fbjs .text-input-text {
background: #f8fafc !important;
border-color: #cbd5e1 !important;
color: #0f172a !important;
}
```
<Info>
Internal Tailwind utility classes are implementation details and may change over time. Prefer CSS variables and stable survey selectors scoped under `#fbjs`.
</Info>
## Best Practices
- Keep all survey overrides in one section or file for easier maintenance.
- Avoid global selectors without `#fbjs` (for example `button`, `input`, `p`) when styling surveys.
- Document why each `!important` exists so future cleanup is easy.
- After changes, hard refresh your page to clear cached SDK assets.
## Troubleshooting
**My app styles still win over survey styles**
- Increase selector specificity under `#fbjs`.
- Add `!important` only on the conflicting property.
- Check the browser inspector to confirm which rule is winning.
**Survey styles are affecting the rest of my app**
- This usually means a selector is missing `#fbjs`.
- Prefix every rule with `#fbjs` to keep styles isolated.
## Survey UI CSS Class Reference
The following classes are used by `packages/survey-ui` and are safe to target when scoped with `#fbjs`.
| CSS class | Element(s) it styles | Notes |
| --- | --- | --- |
| `.button-custom` | Survey action buttons (submit, CTA, navigation buttons with custom variant) | Applies `--fb-button-*` styling tokens. |
| `.label-headline` | Question headlines and headline HTML content | Used by `Label` variant `headline`. |
| `.label-description` | Question descriptions and helper copy | Used by `Label` variant `description`. |
| `.label-default` | Default label text content | Used by `Label` variant `default`. |
| `.label-card` | Upper labels (for example, required label text) | Used by `Label` variant `card`. |
| `.progress-track` | Progress bar track container | Uses `--fb-progress-track-*` tokens. |
| `.progress-indicator` | Progress bar fill indicator | Uses `--fb-progress-indicator-*` tokens. |
| `.rounded-input` | Input-like controls (text inputs, dropdown triggers, date inputs, rating/NPS options) | Controls input border radius token. |
| `.bg-input-bg` | Input-like control backgrounds | Maps to `--fb-input-bg-color`. |
| `.border-input-border` | Input-like control borders | Maps to `--fb-input-border-color`. |
| `.text-input` | Input-like text size | Maps to `--fb-input-font-size`. |
| `.text-input-text` | Input text and some input icons | Maps to `--fb-input-color`. |
| `.text-input-placeholder` | Placeholder and empty-state text | Maps to `--fb-input-placeholder-color`. |
| `.font-input` | Input-like font family | Maps to `--fb-input-font-family`. |
| `.font-input-weight` | Input-like font weight | Maps to `--fb-input-font-weight`. |
| `.w-input` | Input width | Maps to `--fb-input-width`. |
| `.min-h-input` | Input minimum height | Maps to `--fb-input-height`. |
| `.px-input-x` | Input horizontal padding | Maps to `--fb-input-padding-x`. |
| `.py-input-y` | Input vertical padding | Maps to `--fb-input-padding-y`. |
| `.shadow-input` | Input shadow | Maps to `--fb-input-shadow`. |
| `.rounded-option` | Select/multi-select/ranking/picture-select option containers | Controls option border radius token. |
| `.bg-option-bg` | Unselected option backgrounds | Maps to `--fb-option-bg-color`. |
| `.bg-option-selected-bg` | Selected option backgrounds | Used for selected states. |
| `.bg-option-hover-bg` | Option hover background | Used for hover states. |
| `.border-option-border` | Option borders and dropdown search divider | Maps to option border token. |
| `.text-option` | Option label font size | Maps to `--fb-option-font-size`. |
| `.text-option-label` | Option label text color | Maps to `--fb-option-label-color`. |
| `.font-option` | Option label font family | Maps to `--fb-option-font-family`. |
| `.font-option-weight` | Option label font weight | Maps to `--fb-option-font-weight`. |
| `.px-option-x` | Option horizontal padding | Maps to `--fb-option-padding-x`. |
| `.py-option-y` | Option vertical padding | Maps to `--fb-option-padding-y`. |
| `.rounded-button` | Button radius (base button component) | Maps to `--fb-button-border-radius`. |
| `.text-button` | Button text size | Maps to `--fb-button-font-size`. |
| `.font-button-weight` | Button font weight | Maps to `--fb-button-font-weight`. |
| `.border-brand` | Selected/active option borders | Uses survey brand color token. |
-10
View File
@@ -31,14 +31,6 @@ class ValidationError extends Error {
}
}
class ConfigurationError extends Error {
statusCode = 503;
constructor(message: string) {
super(message);
this.name = "ConfigurationError";
}
}
class QueryExecutionError extends Error {
statusCode = 500;
constructor(message: string) {
@@ -151,7 +143,6 @@ export {
ResourceNotFoundError,
InvalidInputError,
ValidationError,
ConfigurationError,
QueryExecutionError,
DatabaseError,
UniqueConstraintError,
@@ -181,7 +172,6 @@ export const EXPECTED_ERROR_NAMES = new Set([
"AuthorizationError",
"InvalidInputError",
"ValidationError",
"ConfigurationError",
"QueryExecutionError",
"AuthenticationError",
"OperationNotAllowedError",
-23
View File
@@ -118,9 +118,6 @@ importers:
'@boxyhq/saml-jackson':
specifier: 26.2.0
version: 26.2.0(@types/node@25.4.0)(ioredis@5.8.1)(socks@2.8.7)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@25.4.0)(typescript@5.9.3))
'@boxyhq/saml20':
specifier: 1.15.2
version: 1.15.2
'@cubejs-client/core':
specifier: 1.6.6
version: 1.6.6(encoding@0.1.13)
@@ -1793,9 +1790,6 @@ packages:
'@boxyhq/saml20@1.13.2':
resolution: {integrity: sha512-iMbFK2I/fB7Iu8qsUTfGBwd2KyVviRPwslxthWMHHSOLbty6GQvrqB07XO3w8kVZVUs20uHfa/azxZO8opMdHA==}
'@boxyhq/saml20@1.15.2':
resolution: {integrity: sha512-kdceDRQMfVft/CdpsKOAwYe44EZPRkqvIt6yh1Hh+ugoab/lKjb13wUvLJwNrWwftMRYrV7oS7N05viu+ljQDw==}
'@bramus/specificity@2.4.2':
resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
hasBin: true
@@ -11876,9 +11870,6 @@ packages:
xml-encryption@3.1.0:
resolution: {integrity: sha512-PV7qnYpoAMXbf1kvQkqMScLeQpjCMixddAKq9PtqVrho8HnYbBOWNfG0kA4R7zxQDo7w9kiYAyzS/ullAyO55Q==}
xml-encryption@4.0.0:
resolution: {integrity: sha512-UvSSRKoDfmyH/ECiKPbhHXMKhcXKOYLva7ifmzitN4BNXLAfdgez+nQANJ3jllmY42D5bdeVvIK0Y7hzcAAlyQ==}
xml-name-validator@5.0.0:
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
engines: {node: '>=18'}
@@ -13571,14 +13562,6 @@ snapshots:
xml2js: 0.6.2
xmlbuilder: 15.1.1
'@boxyhq/saml20@1.15.2':
dependencies:
'@xmldom/xmldom': 0.9.10
xml-crypto: 6.1.2
xml-encryption: 4.0.0
xml2js: 0.6.2
xmlbuilder: 15.1.1
'@bramus/specificity@2.4.2':
dependencies:
css-tree: 3.2.1
@@ -24767,12 +24750,6 @@ snapshots:
escape-html: 1.0.3
xpath: 0.0.32
xml-encryption@4.0.0:
dependencies:
'@xmldom/xmldom': 0.9.10
escape-html: 1.0.3
xpath: 0.0.32
xml-name-validator@5.0.0: {}
xml-naming@0.1.0: {}
+3 -5
View File
@@ -1,5 +1,6 @@
{
"$schema": "https://turborepo.org/schema.json",
"globalEnv": [],
"tasks": {
"@formbricks/ai#build": {
"dependsOn": ["@formbricks/logger#build"],
@@ -215,7 +216,7 @@
"CUBEJS_API_URL",
"CUBEJS_JWT_AUDIENCE",
"CUBEJS_JWT_ISSUER",
"DISABLE_ACCOUNT_DELETION_SSO_REAUTH",
"DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION",
"DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS",
"DATABASE_URL",
"DEBUG",
@@ -228,7 +229,6 @@
"ENVIRONMENT",
"GITHUB_ID",
"GITHUB_SECRET",
"GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED",
"GOOGLE_CLIENT_ID",
"GOOGLE_CLIENT_SECRET",
"GOOGLE_SHEETS_CLIENT_ID",
@@ -322,9 +322,7 @@
"UNSPLASH_ACCESS_KEY",
"PROMETHEUS_ENABLED",
"PROMETHEUS_EXPORTER_PORT",
"USER_MANAGEMENT_MINIMUM_ROLE",
"HUB_API_URL",
"HUB_API_KEY"
"USER_MANAGEMENT_MINIMUM_ROLE"
],
"outputs": ["dist/**", ".next/**"]
},