Compare commits

..

277 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
Matti Nannt 3e5a4ca4c8 refactor: consolidate unifyFeedback into feedbackDirectories license flag (#8034)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 05:28:29 +00:00
Harsh Bhat a7370ac4a0 chore: A/B experiment to test reverse trial copy (#8025)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2026-05-17 20:18:32 +00:00
Harsh Bhat 073c28e5e9 chore: A/B test different upgrade banner in trial (#7953)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 20:03:29 +00:00
Harsh Bhat 104b04bdc0 chore: A/B Test - Onboarding - Skip theme step (#7957)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2026-05-17 18:01:59 +00:00
Harsh Bhat e81360f354 chore: use direct form link on all "request license" buttons instead of /enterprise-license page (#8033) 2026-05-17 16:53:49 +00:00
Harsh Bhat f90a9fb131 chore: A/B experiment to remove CX/Surveys question (#7952)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 12:30:48 +00:00
Javi Aguilar d7627fe6c3 refactor(unify): clarify feedback source terminology and chart builder copy (#8026)
Signed-off-by: gulshank0 <gulshanbahadur002@gmail.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.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: Johannes <jobenjada@users.noreply.github.com>
2026-05-15 20:48:31 +00:00
Dhruwang Jariwala 939fedfca4 feat: Formbricks 5 (#8017)
Signed-off-by: gulshank0 <gulshanbahadur002@gmail.com>
Co-authored-by: Tiago Farto <tiago@formbricks.com>
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Tiago <1585571+xernobyl@users.noreply.github.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: 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: Balázs Úr <balazs@urbalazs.hu>
Co-authored-by: Gulshan <gulshanbahadur002@gmail.com>
Co-authored-by: Harsh Bhat <harsh121102@gmail.com>
Co-authored-by: Javi Aguilar <122741+itsjavi@users.noreply.github.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-15 16:43:27 +00:00
Bhagya Amarasinghe 00f93eec10 fix: skip Docker package removal when Docker is already installed (#7980) 2026-05-15 15:59:35 +02:00
Bhagya Amarasinghe c286a3330a fix: rate limit storage uploads per environment (#8006) 2026-05-15 10:55:17 +00:00
Anshuman Pandey d22db8d735 fix: updates the minimum permission in projectTeam access from read to readWrite (#8020) 2026-05-15 10:13:01 +00:00
Matti Nannt 2db02dac5e docs: add ACCESSIBILITY.md and accessibility issue template (#8019)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 09:53:23 +00:00
Bhagya Amarasinghe ce68d58aaf fix: scope client API rate limits by environment (#8013) 2026-05-15 07:11:08 +00:00
Matti Nannt 6774f220b1 chore: remove all Vercel deployment references (#7997)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-05-15 07:08:58 +00:00
Anshuman Pandey efe259d484 fix: fixes displays and responses api about survey status (#8007) 2026-05-14 13:01:27 +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
Bhagya Amarasinghe 96b08fbe23 fix: single-use survey restriction bypass (#7972) 2026-05-14 09:11:40 +00:00
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
Johannes 4eba194935 fix: clarify signup product updates opt-in (#7994)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-14 07:37:19 +00:00
Anshuman Pandey 0475232bad fix: fixes same survey responses from being reported as failures (#7992) 2026-05-14 10:55:51 +04:00
Dhruwang Jariwala 9d8cf5e0f7 fix(security): reject client-supplied emailVerificationDisabled in signup (ENG-816) (#7993) 2026-05-14 06:47:25 +00:00
Dhruwang Jariwala 3c6f6d83ea fix: Hungarian translation polish (ENG-935) (#8000) 2026-05-14 05:40:03 +00: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
Matti Nannt 1380c81bff fix: patch security dependency vulnerabilities for main (#7990)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 11:59:09 +00:00
Dhruwang Jariwala 5f5860cb23 feat(unify): add delete option for feedback records (ENG-938) (#7991) 2026-05-13 15:56:55 +04:00
Bhagya Amarasinghe 535c111860 fix: improve file upload storage errors (#7978) 2026-05-13 09:25:04 +00:00
Tiago 676e31c433 fix: scope org-only v1 API key auth (#7961) 2026-05-13 07:59:49 +00:00
Dhruwang Jariwala 88f17380e1 fix(i18n): replace fragmented translation concatenations with complete sentences (ENG-706) (#7969)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 07:42:40 +00: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
Tiago 103775b3b1 fix: validate contact_id (#7984) 2026-05-13 06:28:25 +00:00
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
Harsh Bhat 3005c44c49 chore: consolidate CE enterprise trial license links to a single form… (#7929) 2026-05-12 14:27:49 +00:00
Harsh Bhat 4d03ba2ff7 docs: add docs for pretty url (#7932) 2026-05-12 14:26:11 +00:00
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
Tiago bc25c482ad chore: fix broken impressions count (#7975) 2026-05-12 10:32:45 +00:00
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
dependabot[bot] b08f7e4ad9 chore(deps): bump the npm_and_yarn group across 2 directories with 2 updates (#7977)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matti Nannt <matti@formbricks.com>
2026-05-12 06:44:08 +00:00
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
Anshuman Pandey 89a8266ebe fix: fixes webhook ssrf toctou issue (#7954) 2026-05-12 06:03:15 +00:00
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
Tiago cad10b8810 chore: workspace delete confirmation dialog (#7958) 2026-05-11 10:14:02 +00:00
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
Harsh Bhat 1d18b5cb83 docs: fix env var names and add missing entries to reference table (#7964)
Co-authored-by: Harsh Bhat <harsh@formbricks.com>
2026-05-11 09:22:34 +02:00
Javi Aguilar 4dbecc2d58 fix/a11y-select-scroll 2026-05-11 05:52:55 +02:00
Tiago 69ead97965 fix: sso account deletion password check (#7930) 2026-05-08 12:07:58 +00: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 7e2c439325 feat: add PostHog group analytics and feature events (#7914)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Harsh Bhat <harsh121102@gmail.com>
2026-05-07 07:49:27 +00:00
Javi Aguilar a2177eec96 fix: survey modal accessibility issues by using a focus trap (#7939) 2026-05-07 06:14:06 +00: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
Javi Aguilar 255c97854f fix: survey runtime accessibility for keyboard controls (#7927) 2026-05-06 10:21:42 +00:00
Matti Nannt d103499496 fix(security): strip sensitive survey and segment metadata from public client API (#7931)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-05-06 09:54:15 +00:00
Dhruwang Jariwala ed42df34c4 feat(ai): support Vertex AI ADC credentials (#7938) 2026-05-06 12:37:24 +05:30
Matti Nannt b863238f15 refactor: rename gethasNoOrganizations to getHasNoOrganizations (#7940)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 05:03:02 +00:00
Dhruwang Jariwala 8c8ff8e396 feat: gate AI chart generation behind all AI checks (#7937) 2026-05-06 10:09:49 +05:30
Johannes 28280899ea fix: recover incomplete initial setup (#7912) 2026-05-05 14:28:23 +00:00
Matti Nannt bc63870289 feat: add Linear Releases integration to CI pipeline (#7921)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 14:17:48 +00:00
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
Javi Aguilar 9a04e95d15 fix: cal and open text fields a11y semantic improvements (#7936) 2026-05-05 12:31:09 +00:00
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
Bhagya Amarasinghe 9d9f38515d fix: omit replicas when HPA is enabled (#7934) 2026-05-05 10:32:16 +00:00
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 fae00f6a82 fix: outlook preview (#7803)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-05-04 15:10:14 +00:00
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
Anshuman Pandey a274c444ad fix: reject SSO auto-provisioning when AUTH_SSO_DEFAULT_TEAM_ID is missing (#7926) 2026-05-04 10:57:44 +00:00
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
Anshuman Pandey 5fae207cd7 fix: duplicate action class name error (#7919) 2026-04-30 09:17:09 +00:00
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
Johannes 654539d320 fix: removed dead menu item & theme import (#7909)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-30 08:09:40 +00:00
Johannes 44aac89d41 fix: return generic credentials error for SSO-only accounts (#7911)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-04-30 08:09:34 +00:00
Johannes e0250b2a58 fix: include partial response in trigger description (#7908)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-30 06:27:48 +00:00
Labeeb a8d6cd8a9f fix: 7817 use fully translated inactive survey headings (#7836) 2026-04-30 04:46:13 +00:00
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 f79fe1490e feat: add PostHog experiment wrappers (#7899) 2026-04-29 08:07:11 +00:00
Dhruwang Jariwala 5cfbc671c5 fix: allow back navigation to prefilled questions in email embed surveys (#7900) 2026-04-29 08:06:08 +00:00
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
Tiago e6e9419b93 fix: require step-up authorization for account deletion (#7901)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-28 12:46:27 +00:00
dependabot[bot] 90eb78e571 chore(deps): bump the npm_and_yarn group across 5 directories with 2 updates (#7834)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matti Nannt <matti@formbricks.com>
2026-04-28 12:01:12 +02:00
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
Johannes 991866f549 docs: document option ID prefilling (#7893)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-04-28 08:17:05 +00:00
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
Bhagya Amarasinghe 6d1b3475d4 fix(survey-list): reduce v3 overview query cost (#7812) 2026-04-27 16:53:53 +00:00
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
Johannes 5e967a2b67 fix: use app.formbricks.com in v1 API docs (#7894)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-04-27 13:19:12 +00:00
Bhagya Amarasinghe bcc3789ce8 refactor: generalize envoy auth dispatch 2026-04-27 18:31:58 +05:30
Johannes 23a8fd6a47 fix: replace organization name placeholder example (#7832)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-27 12:49:16 +00:00
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
Dhruwang Jariwala 0686eb3cbb feat: add refetch button to refresh responses table (#7808)
Co-authored-by: Johannes <johannes@formbricks.com>
2026-04-27 10:10:53 +00:00
Anshuman Pandey 6d9ab315c2 fix: prevent SSRF via redirect following in webhook delivery (#7877) 2026-04-27 08:50:21 +00:00
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
Tiago 4128731c5f fix: password hash visibility improvement (#7814)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-24 18:16:59 +00:00
Matti Nannt ef96426ca0 chore(security): dependency audit — reduce attack surface & resolve all vulnerabilities (#7801)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 20:32:32 +02:00
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 ce1dbe8b00 fix: apply plan changes immediately for non-standard plans (#7807) 2026-04-24 07:38:33 +00:00
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
Tiago Farto 7aa12a4f0c chore: rename google ai things 2026-04-23 12:27:39 +00:00
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 Jariwala 444f043140 fix: prevent Airtable integration crash when token expires (#7811)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 08:36:10 +00:00
Dhruwang Jariwala 2d32c0d671 feat: add iframe preview to website embed tab (#7791)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 06:36:55 +00:00
Dhruwang Jariwala 8dc70a5e30 fix: prevent survey widget CSS from polluting host page styles (#7805)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 12:24:44 +00:00
Aryan Ghugare 3e4e55fbf1 fix: prevent bypass of single-use survey restriction via v1 API (#7735)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-22 09:48:18 +00:00
Tiago fcfedd6e15 feat: replace minio with rustfs (#7742) 2026-04-22 08:07:38 +00:00
Tiago 6c4342690f fix: harden legacy SSO relinking (#7755) 2026-04-22 07:35:23 +00:00
arasucar b8c361fcf3 refactor: use context instead of prop drilling in survey analysis components (#6223) (#7754)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-22 06:00:13 +00:00
Matti Nannt 8771a0ec91 fix: lodash vulnerability (#7800) 2026-04-22 05:57:08 +00:00
Bhagya Amarasinghe fc33c52133 fix: patch protobufjs transitive vulnerabilities (#7790) 2026-04-21 09:59:12 +00:00
Balázs Úr 75cf9293b1 fix: Hungarian translation (#7752) 2026-04-21 08:41:53 +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
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
916 changed files with 46673 additions and 18283 deletions
-1
View File
@@ -1 +0,0 @@
{"sessionId":"f77248e2-8840-41c6-968b-c3b7d8a9e913","pid":49125,"acquiredAt":1776168010367}
-1
View File
@@ -349,4 +349,3 @@ When creating a new question element, verify:
- [ ] TypeScript types properly exported
- [ ] Error message display included if applicable
- [ ] Disabled state supported if applicable
@@ -1,72 +0,0 @@
---
name: csv-mapping-ui
overview: "Simplify the unreleased CSV mapping flow by matching the Formbricks Survey connectors default-heavy behavior: hide predefined/internal fields, auto-map likely CSV columns, and present only the fields users need to review."
todos:
- id: define-csv-field-model
content: Define CSV-specific mapping groups, hidden static fields, required UI fields, aliases, and confidence metadata.
status: pending
- id: build-auto-mapping
content: Implement auto-mapping from CSV headers, sample values, and filename, including `$now`, `csv`, and response-value routing.
status: pending
- id: refactor-mapping-ui
content: Render grouped CSV mapping UI with Basic, Source Context, and collapsed Advanced sections while removing Tenant ID.
status: pending
- id: update-validation-transform
content: Align create/edit validation and import transform with required `field_label`, hidden `source_type`, and synthetic `response_value`.
status: pending
- id: add-utility-tests
content: Add Vitest coverage for auto-mapping, hidden/static mappings, filename defaults, and response-value routing.
status: pending
isProject: false
---
# CSV Mapping UI Plan
## Current Comparison
- Formbricks Survey connector in [`/Users/johannes/Developer/formbricks/formbricks/apps/web/app/(app)/workspaces/[workspaceId]/unify/sources/components/create-connector-modal.tsx`](</Users/johannes/Developer/formbricks/formbricks/apps/web/app/(app)/workspaces/[workspaceId]/unify/sources/components/create-connector-modal.tsx>) prepopulates `sourceName`, selects all supported survey questions after survey selection, defaults `importHistorical` to true, and derives Hub `field_type` server-side via [`/Users/johannes/Developer/formbricks/formbricks/apps/web/lib/connector/actions.ts`](/Users/johannes/Developer/formbricks/formbricks/apps/web/lib/connector/actions.ts).
- CSV currently only defaults connector name and directory; it exposes all `FEEDBACK_RECORD_FIELDS`, including `tenant_id`, through [`MappingUI`](</Users/johannes/Developer/formbricks/formbricks/apps/web/app/(app)/workspaces/[workspaceId]/unify/sources/components/mapping-ui.tsx>).
- CSV import already backfills missing `tenant_id` from `connector.feedbackRecordDirectoryId` in [`/Users/johannes/Developer/formbricks/formbricks/apps/web/lib/connector/csv-import.ts`](/Users/johannes/Developer/formbricks/formbricks/apps/web/lib/connector/csv-import.ts) and [`/Users/johannes/Developer/formbricks/formbricks/apps/web/lib/connector/csv-transform.ts`](/Users/johannes/Developer/formbricks/formbricks/apps/web/lib/connector/csv-transform.ts), so the UI should not ask for it.
## Proposed UI Shape
- Keep a single CSV configuration screen: source name, upload/dropzone, preview, then mapping review. No additional wizard step.
- Replace the raw required/optional target list with grouped sections:
- Basic required: `collected_at`, `field_id`, `field_label`, `field_type`, `response_value`.
- Source context: `source_id`, `source_name`.
- Advanced fields, collapsed: `language`, `user_identifier`, `metadata`, and any less common optional targets.
- Drop `tenant_id` from the UI entirely.
- Hide `source_type` from the UI and save it as static `csv`.
- Prepopulate `source_name` from the uploaded CSV file name, while keeping it editable.
- Auto-map immediately after upload and display review/confidence indicators on mapped fields so users can fix uncertain matches without starting from blank.
## Prepopulation Rules
- `collected_at`: map likely timestamp columns (`timestamp`, `created_at`, `date`, `submitted_at`, etc.); otherwise set static `$now`.
- `source_type`: static `csv`, hidden.
- `source_name`: static uploaded CSV filename, editable.
- `field_id` and `field_label`: both visible and required in the CSV UI; auto-map likely id/question/label columns, but block save if unresolved.
- `field_type`: keep visible and required; auto-suggest from header/sample value, but require a valid enum.
- `response_value`: show one user-facing control and internally route to `value_text`, `value_number`, `value_boolean`, or `value_date` based on selected `field_type`.
- Advanced fields: auto-map if obvious (`language`, `user_id`, `metadata`) but keep them collapsed by default.
## Implementation Approach
- Add CSV-specific field configuration and auto-mapping utilities near [`/Users/johannes/Developer/formbricks/formbricks/apps/web/app/(app)/workspaces/[workspaceId]/unify/sources/types.ts`](</Users/johannes/Developer/formbricks/formbricks/apps/web/app/(app)/workspaces/[workspaceId]/unify/sources/types.ts>) and [`/Users/johannes/Developer/formbricks/formbricks/apps/web/app/(app)/workspaces/[workspaceId]/unify/sources/utils.ts`](</Users/johannes/Developer/formbricks/formbricks/apps/web/app/(app)/workspaces/[workspaceId]/unify/sources/utils.ts>): matching aliases, confidence level, hidden static mappings, and response-value routing.
- Refactor [`MappingUI`](</Users/johannes/Developer/formbricks/formbricks/apps/web/app/(app)/workspaces/[workspaceId]/unify/sources/components/mapping-ui.tsx>) to render CSV-specific groups instead of `requiredFields` and `optionalFields` directly from `FEEDBACK_RECORD_FIELDS`.
- Update [`CsvConnectorUI`](</Users/johannes/Developer/formbricks/formbricks/apps/web/app/(app)/workspaces/[workspaceId]/unify/sources/components/csv-connector-ui.tsx>) so file upload triggers auto-mapping using headers, first-row samples, and CSV filename.
- Update create/edit validation in [`CreateConnectorModal`](</Users/johannes/Developer/formbricks/formbricks/apps/web/app/(app)/workspaces/[workspaceId]/unify/sources/components/create-connector-modal.tsx>), [`EditConnectorModal`](</Users/johannes/Developer/formbricks/formbricks/apps/web/app/(app)/workspaces/[workspaceId]/unify/sources/components/edit-connector-modal.tsx>), and [`connector-form-utils.ts`](</Users/johannes/Developer/formbricks/formbricks/apps/web/app/(app)/workspaces/[workspaceId]/unify/sources/components/connector-form-utils.ts>) to require the CSV UI basics, including `field_label` and `response_value`, not just the raw backend-required fields.
- Update CSV transform/import logic so the synthetic `response_value` mapping is converted to the correct backend target based on `field_type` before creating feedback records.
- Remove unreleased compatibility shims rather than preserving old UI behavior; existing branch data can be replaced by the new mapping contract.
- Add focused Vitest coverage for the new auto-mapping and response-value routing utilities. Avoid `.tsx` component tests per repo guidance.
## Main Edge Cases To Cover
- CSV has no timestamp column: `collected_at` becomes `$now`.
- CSV has ambiguous timestamp columns: choose highest-confidence alias and mark reviewable.
- CSV has no field label/id columns: save is blocked with clear validation.
- CSV field type conflicts with response sample value: route by `field_type`, surface parse failures in existing import result counts/errors.
- CSV filename changes after remapping: update `source_name` only if the user has not manually edited it.
- Advanced auto-detected fields remain editable even while the section is collapsed.
- Hidden `tenant_id` is never persisted from user input; backend predefined value remains source of truth.
- Hidden `source_type=csv` is included in saved mappings/import payload so rows are valid without user action.
+42 -24
View File
@@ -63,7 +63,28 @@ DATABASE_URL='postgresql://postgres:postgres@localhost:5432/formbricks?schema=pu
# Set explicitly to avoid confusion; override as needed when using docker-compose.dev.yml.
HUB_API_KEY=dev-api-key
HUB_API_URL=http://localhost:8080
HUB_DATABASE_URL=postgresql://postgres:postgres@postgres:5432/postgres?sslmode=disable
HUB_DATABASE_URL=postgresql://postgres:postgres@postgres:5432/hub?sslmode=disable
# Hub image tag used by docker-compose.dev.yml (hub + hub-migrate). Leave unset to use the
# pinned default in the compose file; override here when testing a specific Hub release.
# HUB_IMAGE_TAG=0.3.0
# Hub embeddings are optional. Set a provider and model to enable semantic search embeddings in
# the Hub API and hub-worker. For provider-specific settings, see:
# https://hub.formbricks.com/reference/environment-variables/#embeddings
# Example with Google AI Studio:
# EMBEDDING_PROVIDER=google
# EMBEDDING_MODEL=gemini-embedding-001
# EMBEDDING_PROVIDER_API_KEY=
####################
# 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=
CUBEJS_JWT_ISSUER=formbricks-web
CUBEJS_JWT_AUDIENCE=formbricks-cube
################
# MAIL SETUP #
@@ -97,7 +118,7 @@ SMTP_PASSWORD=smtpPassword
# S3 STORAGE #
##############
# S3 Storage is required for the file upload in serverless environments like Vercel
# S3 Storage is required for the file upload in serverless environments
S3_ACCESS_KEY=
S3_SECRET_KEY=
S3_REGION=
@@ -133,6 +154,14 @@ PASSWORD_RESET_DISABLED=1
# Organization Invite. Disable the ability for invited users to create an account.
# INVITE_DISABLED=1
###########################################
# Account deletion SSO confirmation #
###########################################
# 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
##########
# Other #
@@ -167,16 +196,17 @@ AZUREAD_TENANT_ID=
# Configure Formbricks AI at the instance level
# Set the provider used for AI features on this instance.
# Accepted values for AI_PROVIDER: aws, gcp, azure
# Set AI_MODEL to the provider-specific model or deployment name and configure the matching credentials below.
# AI_PROVIDER=gcp
# Accepted values for AI_PROVIDER: aws, google, azure
# Set AI_MODEL to the provider-specific model or deployment name and configure the matching provider settings below.
# AI_PROVIDER=google
# AI_MODEL=gemini-2.5-flash
# Google Vertex AI credentials
# AI_GCP_PROJECT=
# AI_GCP_LOCATION=
# AI_GCP_CREDENTIALS_JSON=
# AI_GCP_APPLICATION_CREDENTIALS=
# Google Cloud settings for Gemini models
# Credentials are optional when Application Default Credentials are available.
# AI_GOOGLE_CLOUD_PROJECT=
# AI_GOOGLE_CLOUD_LOCATION=
# AI_GOOGLE_CLOUD_CREDENTIALS_JSON=
# AI_GOOGLE_CLOUD_APPLICATION_CREDENTIALS=
# Amazon Bedrock credentials
# AI_AWS_REGION=
@@ -295,25 +325,13 @@ REDIS_URL=redis://localhost:6379
# If the ip should be added in the log or not. Default 0
# AUDIT_LOG_GET_USER_IP=0
# Cube.js Analytics (optional — only needed for the analytics/dashboard feature)
# Required when running the Cube service (docker-compose.dev.yml). Generate with: openssl rand -hex 32
# Use the same value for CUBEJS_API_TOKEN so the client can authenticate.
# CUBEJS_API_SECRET=
# URL where the Cube.js instance is running
# CUBEJS_API_URL=http://localhost:4000
# API token sent with each Cube.js request; must match CUBEJS_API_SECRET when CUBEJS_DEV_MODE is off
# CUBEJS_API_TOKEN=
#
# Cube connects to the local Postgres service by default in docker-compose.dev.yml.
# Override these only if your Hub DB runs on a different host.
# Optional Cube.js database overrides. The official local docker-compose.dev.yml stack points Cube at the
# local `postgres` service automatically; set these only when running Cube yourself or changing bundled defaults.
# CUBEJS_DB_HOST=postgres
# CUBEJS_DB_PORT=5432
# CUBEJS_DB_NAME=postgres
# CUBEJS_DB_USER=postgres
# CUBEJS_DB_PASS=postgres
#
# Alternative (when not on same Docker network): host.docker.internal and port 5433
# Lingo.dev API key for translation generation
LINGO_API_KEY=your_api_key_here
+78
View File
@@ -0,0 +1,78 @@
name: Accessibility issue
description: "Report an accessibility barrier in Formbricks (WCAG, screen reader, keyboard, contrast, etc.)"
type: bug
labels: ["accessibility", "bug"]
body:
- type: markdown
attributes:
value: |
Thanks for helping make Formbricks accessible to everyone. Please fill in as much as you can — see [ACCESSIBILITY.md](https://github.com/formbricks/formbricks/blob/main/ACCESSIBILITY.md) for context.
- type: textarea
id: summary
attributes:
label: Summary
description: What part of Formbricks is affected and what's wrong?
placeholder: "e.g. The language switcher in survey runtime can't be reached with Tab."
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual behavior
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
placeholder: |
1. Open a survey with multiple languages
2. Press Tab repeatedly
3. Focus never lands on the language switcher
validations:
required: true
- type: input
id: wcag
attributes:
label: Related WCAG criterion (if known)
placeholder: "e.g. 2.1.1 Keyboard"
- type: dropdown
id: severity
attributes:
label: Severity
options:
- "Critical — blocks a user from completing a core task"
- "High — significant barrier with no easy workaround"
- "Medium — barrier with a workaround"
- "Low — minor friction"
validations:
required: true
- type: input
id: at
attributes:
label: Assistive technology
placeholder: "e.g. NVDA 2026.1, VoiceOver on macOS 15, keyboard only"
- type: input
id: browser
attributes:
label: Browser and OS
placeholder: "e.g. Firefox 138 on Windows 11"
- type: dropdown
id: environment
attributes:
label: Your Environment
options:
- Formbricks Cloud (app.formbricks.com)
- Self-hosted Formbricks
validations:
required: true
- type: textarea
id: other
attributes:
label: Other information (screenshots, recordings, axe output)
@@ -284,6 +284,10 @@ runs:
database_url=${{ env.DUMMY_DATABASE_URL }}
encryption_key=${{ env.DUMMY_ENCRYPTION_KEY }}
redis_url=${{ env.DUMMY_REDIS_URL }}
hub_api_url=${{ env.DUMMY_HUB_API_URL }}
hub_api_key=${{ env.DUMMY_HUB_API_KEY }}
cubejs_api_url=${{ env.DUMMY_CUBEJS_API_URL }}
cubejs_api_secret=${{ env.DUMMY_CUBEJS_API_SECRET }}
sentry_auth_token=${{ env.SENTRY_AUTH_TOKEN }}
posthog_key=${{ env.POSTHOG_KEY }}
env:
@@ -291,6 +295,10 @@ runs:
DUMMY_DATABASE_URL: ${{ env.DUMMY_DATABASE_URL }}
DUMMY_ENCRYPTION_KEY: ${{ env.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ env.DUMMY_REDIS_URL }}
DUMMY_HUB_API_URL: ${{ env.DUMMY_HUB_API_URL }}
DUMMY_HUB_API_KEY: ${{ env.DUMMY_HUB_API_KEY }}
DUMMY_CUBEJS_API_URL: ${{ env.DUMMY_CUBEJS_API_URL }}
DUMMY_CUBEJS_API_SECRET: ${{ env.DUMMY_CUBEJS_API_SECRET }}
SENTRY_AUTH_TOKEN: ${{ env.SENTRY_AUTH_TOKEN }}
POSTHOG_KEY: ${{ env.POSTHOG_KEY }}
+7 -9
View File
@@ -20,12 +20,12 @@ runs:
using: "composite"
steps:
- name: Checkout repo
uses: actions/checkout@v3
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: ./.github/actions/dangerous-git-checkout
- name: Cache Build
uses: actions/cache@v3
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
id: cache-build
env:
cache-name: prod-build
@@ -43,7 +43,7 @@ runs:
shell: bash
- name: Setup Node.js 20.x
uses: actions/setup-node@v3
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 20.x
if: steps.cache-build.outputs.cache-hit != 'true'
@@ -53,20 +53,18 @@ runs:
if: steps.cache-build.outputs.cache-hit != 'true'
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
if: steps.cache-build.outputs.cache-hit != 'true'
shell: bash
- name: create .env
run: cp .env.example .env
- name: Create .env
run: pnpm dev:setup
shell: bash
- name: Fill ENCRYPTION_KEY, ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env
- name: Fill E2E_TESTING in .env
env:
E2E_TESTING_MODE: ${{ inputs.e2e_testing_mode }}
run: |
RANDOM_KEY=$(openssl rand -hex 32)
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
echo "E2E_TESTING=$E2E_TESTING_MODE" >> .env
shell: bash
@@ -4,7 +4,7 @@ runs:
using: "composite"
steps:
- name: Checkout repo
uses: actions/checkout@v3
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 2
+4
View File
@@ -91,5 +91,9 @@ jobs:
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
DUMMY_HUB_API_URL: ${{ secrets.DUMMY_HUB_API_URL }}
DUMMY_HUB_API_KEY: ${{ secrets.DUMMY_HUB_API_KEY }}
DUMMY_CUBEJS_API_URL: ${{ secrets.DUMMY_CUBEJS_API_URL }}
DUMMY_CUBEJS_API_SECRET: ${{ secrets.DUMMY_CUBEJS_API_SECRET }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
+1 -1
View File
@@ -49,7 +49,7 @@ jobs:
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
- name: Run Chromatic
uses: chromaui/action@4c20b95e9d3209ecfdf9cd6aace6bbde71ba1694 # v13.3.4
@@ -73,6 +73,10 @@ jobs:
database_url=${{ secrets.DUMMY_DATABASE_URL }}
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
redis_url=redis://localhost:6379
hub_api_url=http://localhost:4000
hub_api_key=build-time-placeholder
cubejs_api_url=http://localhost:4000
cubejs_api_secret=build-time-placeholder
- name: Verify and Initialize PostgreSQL
run: |
@@ -143,6 +147,10 @@ jobs:
-e DATABASE_URL="postgresql://test:test@host.docker.internal:5432/formbricks" \
-e ENCRYPTION_KEY="$DUMMY_ENCRYPTION_KEY" \
-e REDIS_URL="redis://host.docker.internal:6379" \
-e HUB_API_URL="http://localhost:4000" \
-e HUB_API_KEY="build-time-placeholder" \
-e CUBEJS_API_URL="http://localhost:4000" \
-e CUBEJS_API_SECRET="build-time-placeholder" \
-d "formbricks-test:$GITHUB_SHA"
# Start health check polling immediately (every 5 seconds for up to 5 minutes)
+40 -55
View File
@@ -57,7 +57,7 @@ jobs:
- uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js 22.x
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22.x
@@ -65,19 +65,15 @@ jobs:
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
shell: bash
- name: create .env
run: cp .env.example .env
- name: Create .env
run: pnpm dev:setup
shell: bash
- name: Fill ENCRYPTION_KEY, ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env
- name: Fill ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env
run: |
RANDOM_KEY=$(openssl rand -hex 32)
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${{ secrets.ENTERPRISE_LICENSE_KEY }}/" .env
sed -i "s|REDIS_URL=.*|REDIS_URL=redis://localhost:6379|" .env
echo "" >> .env
@@ -85,65 +81,48 @@ jobs:
echo "S3_REGION=us-east-1" >> .env
echo "S3_BUCKET_NAME=formbricks-e2e" >> .env
echo "S3_ENDPOINT_URL=http://localhost:9000" >> .env
echo "S3_ACCESS_KEY=devminio" >> .env
echo "S3_SECRET_KEY=devminio123" >> .env
echo "S3_ACCESS_KEY=devrustfs-service" >> .env
echo "S3_SECRET_KEY=devrustfs-service123" >> .env
echo "S3_FORCE_PATH_STYLE=1" >> .env
shell: bash
- name: Install MinIO client (mc)
run: |
set -euo pipefail
MC_VERSION="RELEASE.2025-08-13T08-35-41Z"
MC_BASE="https://dl.min.io/client/mc/release/linux-amd64/archive"
MC_BIN="mc.${MC_VERSION}"
MC_SUM="${MC_BIN}.sha256sum"
curl -fsSL "${MC_BASE}/${MC_BIN}" -o "${MC_BIN}"
curl -fsSL "${MC_BASE}/${MC_SUM}" -o "${MC_SUM}"
sha256sum -c "${MC_SUM}"
chmod +x "${MC_BIN}"
sudo mv "${MC_BIN}" /usr/local/bin/mc
- name: Start MinIO Server
- name: Start RustFS Server
run: |
set -euo pipefail
# Start MinIO server in background
# Start RustFS server in background
docker run -d \
--name minio-server \
--name rustfs-server \
-p 9000:9000 \
-p 9001:9001 \
-e MINIO_ROOT_USER=devminio \
-e MINIO_ROOT_PASSWORD=devminio123 \
minio/minio:RELEASE.2025-09-07T16-13-09Z \
server /data --console-address :9001
-e RUSTFS_ACCESS_KEY=devrustfs \
-e RUSTFS_SECRET_KEY=devrustfs123 \
-e RUSTFS_ADDRESS=:9000 \
-e RUSTFS_CONSOLE_ENABLE=true \
-e RUSTFS_CONSOLE_ADDRESS=:9001 \
rustfs/rustfs:1.0.0-alpha.93 \
/data
echo "MinIO server started"
echo "RustFS server started"
- name: Wait for MinIO and create S3 bucket
- name: Bootstrap RustFS bucket and browser upload CORS
run: |
set -euo pipefail
echo "Waiting for MinIO to be ready..."
ready=0
for i in {1..60}; do
if curl -fsS http://localhost:9000/minio/health/live >/dev/null; then
echo "MinIO is up after ${i} seconds"
ready=1
break
fi
sleep 1
done
if [ "$ready" -ne 1 ]; then
echo "::error::MinIO did not become ready within 60 seconds"
exit 1
fi
mc alias set local http://localhost:9000 devminio devminio123
mc mb --ignore-existing local/formbricks-e2e
docker run --rm \
--network host \
--entrypoint /bin/sh \
-e RUSTFS_ENDPOINT_URL=http://127.0.0.1:9000 \
-e RUSTFS_ADMIN_USER=devrustfs \
-e RUSTFS_ADMIN_PASSWORD=devrustfs123 \
-e RUSTFS_SERVICE_USER=devrustfs-service \
-e RUSTFS_SERVICE_PASSWORD=devrustfs-service123 \
-e RUSTFS_BUCKET_NAME=formbricks-e2e \
-e RUSTFS_POLICY_NAME=formbricks-e2e-policy \
-e RUSTFS_CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 \
-v "$PWD/docker/rustfs-init.sh:/tmp/rustfs-init.sh:ro" \
minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868 \
/tmp/rustfs-init.sh
- name: Build App
run: |
@@ -242,8 +221,14 @@ jobs:
if: failure()
with:
name: app-logs
if-no-files-found: ignore
path: app.log
- name: Output App Logs
if: failure()
run: cat app.log
run: |
if [ -f app.log ]; then
cat app.log
else
echo "app.log not found because the Run App step did not execute or failed before log creation."
fi
+28
View File
@@ -155,3 +155,31 @@ jobs:
commit_sha: ${{ github.sha }}
is_prerelease: ${{ github.event.release.prerelease }}
make_latest: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
linear-release-complete:
name: Mark Linear release as complete
runs-on: ubuntu-latest
timeout-minutes: 5
needs:
- docker-build-community
- docker-build-cloud
- helm-chart-release
- move-stable-tag
if: ${{ !github.event.release.prerelease }}
steps:
- name: Harden the runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Complete Linear release
uses: linear/linear-release-action@0353b5fa8c00326913966f00557d68f8f30b8b6b # v0.7.0
with:
access_key: ${{ secrets.LINEAR_ACCESS_KEY }}
command: complete
version: ${{ github.event.release.tag_name }}
+30
View File
@@ -0,0 +1,30 @@
name: Linear Release Sync
on:
push:
branches:
- main
permissions:
contents: read
jobs:
linear-release:
name: Sync release to Linear
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Harden the runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Sync Linear release
uses: linear/linear-release-action@0353b5fa8c00326913966f00557d68f8f30b8b6b # v0.7.0
with:
access_key: ${{ secrets.LINEAR_ACCESS_KEY }}
+4 -11
View File
@@ -21,7 +21,7 @@ jobs:
- uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js 20.x
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 20.x
@@ -29,17 +29,10 @@ jobs:
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
- name: create .env
run: cp .env.example .env
- name: Generate Random ENCRYPTION_KEY, CRON_SECRET & NEXTAUTH_SECRET and fill in .env
run: |
RANDOM_KEY=$(openssl rand -hex 32)
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
- name: Create .env
run: pnpm dev:setup
- name: Lint
run: pnpm lint
@@ -47,4 +47,8 @@ jobs:
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
DUMMY_HUB_API_URL: ${{ secrets.DUMMY_HUB_API_URL }}
DUMMY_HUB_API_KEY: ${{ secrets.DUMMY_HUB_API_KEY }}
DUMMY_CUBEJS_API_URL: ${{ secrets.DUMMY_CUBEJS_API_URL }}
DUMMY_CUBEJS_API_SECRET: ${{ secrets.DUMMY_CUBEJS_API_SECRET }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
@@ -105,4 +105,8 @@ jobs:
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
DUMMY_HUB_API_URL: ${{ secrets.DUMMY_HUB_API_URL }}
DUMMY_HUB_API_KEY: ${{ secrets.DUMMY_HUB_API_KEY }}
DUMMY_CUBEJS_API_URL: ${{ secrets.DUMMY_CUBEJS_API_URL }}
DUMMY_CUBEJS_API_SECRET: ${{ secrets.DUMMY_CUBEJS_API_SECRET }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
+5 -9
View File
@@ -25,7 +25,7 @@ jobs:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: Setup Node.js 22.x
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22.x
@@ -33,17 +33,13 @@ jobs:
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
- name: create .env
run: cp .env.example .env
- name: Create .env
run: pnpm dev:setup
- name: Generate Random ENCRYPTION_KEY, CRON_SECRET & NEXTAUTH_SECRET and fill in .env
- name: Adjust CI-specific env values
run: |
RANDOM_KEY=$(openssl rand -hex 32)
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
sed -i "s|REDIS_URL=.*|REDIS_URL=|" .env
- name: Run tests with coverage
+5 -9
View File
@@ -22,7 +22,7 @@ jobs:
- uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js 20.x
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 20.x
@@ -30,17 +30,13 @@ jobs:
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
- name: create .env
run: cp .env.example .env
- name: Create .env
run: pnpm dev:setup
- name: Generate Random ENCRYPTION_KEY, CRON_SECRET & NEXTAUTH_SECRET and fill in .env
- name: Adjust CI-specific env values
run: |
RANDOM_KEY=$(openssl rand -hex 32)
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
sed -i "s|REDIS_URL=.*|REDIS_URL=|" .env
- name: Test
+3 -2
View File
@@ -2,6 +2,7 @@ name: Translation Validation
permissions:
contents: read
pull-requests: read
on:
pull_request:
@@ -39,7 +40,7 @@ jobs:
- name: Setup Node.js 22.x
if: steps.changes.outputs.translations == 'true'
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22.x
@@ -49,7 +50,7 @@ jobs:
- name: Install dependencies
if: steps.changes.outputs.translations == 'true'
run: pnpm install --config.platform=linux --config.architecture=x64
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
- name: Validate translation keys
if: steps.changes.outputs.translations == 'true'
-1
View File
@@ -1 +0,0 @@
apps/web/.env
+48
View File
@@ -0,0 +1,48 @@
# Accessibility
Formbricks is committed to making our platform usable by everyone, including people who rely on assistive technologies.
## Standards
We aim to conform to:
- **[WCAG 2.1 Level AA](https://www.w3.org/TR/WCAG21/)** — the web content baseline.
- **[EN 301 549](https://www.etsi.org/deliver/etsi_en/301500_301599/301549/)** — the European harmonised standard referenced by the **European Accessibility Act (EAA)**, applicable to us as a Germany-based company.
- **Section 508** — for users in US public-sector contexts.
## Priorities
1. **End-user surveys** (`packages/surveys`) — everything respondents see and interact with. This is our highest priority because survey takers don't choose Formbricks; the organisations running surveys choose for them.
2. **Admin app** (`apps/web`) — survey creation, response analysis, and team management used by Formbricks customers.
In both areas we focus on:
- Keyboard navigation with a clearly visible focus indicator
- Screen reader support through semantic HTML and correctly scoped ARIA
- Sufficient color and contrast
- Programmatically associated labels and announced status messages
## Supported Environments
- Latest two versions of Chrome, Firefox, Safari, and Edge
- VoiceOver (macOS/iOS), NVDA (Windows), and TalkBack (Android)
## Contributing
When contributing UI changes:
- Prefer semantic HTML over ARIA.
- Tab through your change end-to-end and confirm focus is visible at every stop.
- Label every control. Don't convey meaning by color alone.
- Run [axe DevTools](https://www.deque.com/axe/devtools/) or Lighthouse on the page you changed.
## Reporting Accessibility Issues
If you encounter an accessibility barrier, please [open an issue](https://github.com/formbricks/formbricks/issues/new?labels=accessibility&template=accessibility.yml) using the accessibility template. For blocking issues in a procurement or compliance context, email **[hola@formbricks.com](mailto:hola@formbricks.com)**.
## Resources
- [WCAG 2.1 Quick Reference](https://www.w3.org/WAI/WCAG21/quickref/)
- [EN 301 549](https://www.etsi.org/deliver/etsi_en/301500_301599/301549/)
- [European Accessibility Act overview](https://ec.europa.eu/social/main.jsp?catId=1202)
- [MDN Accessibility Reference](https://developer.mozilla.org/en-US/docs/Web/Accessibility)
+12 -12
View File
@@ -11,19 +11,19 @@
"clean": "rimraf .turbo node_modules dist storybook-static"
},
"devDependencies": {
"@chromatic-com/storybook": "^5.0.1",
"@storybook/addon-a11y": "10.2.17",
"@storybook/addon-links": "10.2.17",
"@storybook/addon-onboarding": "10.2.17",
"@storybook/react-vite": "10.2.17",
"@typescript-eslint/eslint-plugin": "8.57.0",
"@tailwindcss/vite": "4.2.1",
"@typescript-eslint/parser": "8.57.0",
"@chromatic-com/storybook": "5.0.2",
"@storybook/addon-a11y": "10.3.6",
"@storybook/addon-docs": "10.3.6",
"@storybook/addon-links": "10.3.6",
"@storybook/addon-onboarding": "10.3.6",
"@storybook/react-vite": "10.3.6",
"@tailwindcss/vite": "4.2.4",
"@typescript-eslint/eslint-plugin": "8.57.2",
"@typescript-eslint/parser": "8.57.2",
"@vitejs/plugin-react": "5.1.4",
"eslint-plugin-react-refresh": "0.4.26",
"eslint-plugin-storybook": "10.2.17",
"storybook": "10.2.17",
"vite": "7.3.2",
"@storybook/addon-docs": "10.2.17"
"eslint-plugin-storybook": "10.3.6",
"storybook": "10.3.6",
"vite": "7.3.3"
}
}
+4
View File
@@ -66,6 +66,10 @@ RUN pnpm build --filter=@formbricks/database
RUN --mount=type=secret,id=database_url \
--mount=type=secret,id=encryption_key \
--mount=type=secret,id=redis_url \
--mount=type=secret,id=hub_api_url \
--mount=type=secret,id=hub_api_key \
--mount=type=secret,id=cubejs_api_url \
--mount=type=secret,id=cubejs_api_secret \
--mount=type=secret,id=sentry_auth_token \
--mount=type=secret,id=posthog_key \
/tmp/read-secrets.sh pnpm build --filter=@formbricks/web...
@@ -1,4 +1,4 @@
import { Prisma } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
@@ -32,7 +32,7 @@ describe("getTeamsByOrganizationId", () => {
test("throws DatabaseError on Prisma error", async () => {
vi.mocked(prisma.team.findMany).mockRejectedValueOnce(
new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
new PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
);
await expect(getTeamsByOrganizationId("org1")).rejects.toThrow(DatabaseError);
});
@@ -1,6 +1,6 @@
"use server";
import { Prisma } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
@@ -27,7 +27,7 @@ export const getTeamsByOrganizationId = reactCache(
name: team.name,
}));
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error instanceof PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
@@ -1,20 +1,32 @@
"use client";
import { ArrowUpRightIcon, ChevronRightIcon, LogOutIcon } from "lucide-react";
import {
ArrowUpRightIcon,
Building2Icon,
ChevronRightIcon,
Loader2,
LogOutIcon,
PlusIcon,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState, useTransition } from "react";
import { useTranslation } from "react-i18next";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import { getOrganizationsForSwitcherAction } from "@/app/(app)/workspaces/[workspaceId]/actions";
import FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
import { ProfileAvatar } from "@/modules/ui/components/avatars";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
@@ -22,14 +34,65 @@ import {
interface LandingSidebarProps {
user: TUser;
organization: TOrganization;
isMultiOrgEnabled: boolean;
}
export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState<boolean>(false);
export const LandingSidebar = ({ user, organization, isMultiOrgEnabled }: LandingSidebarProps) => {
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState(false);
const [organizations, setOrganizations] = useState<{ id: string; name: string }[]>([]);
const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(false);
const [organizationLoadError, setOrganizationLoadError] = useState<string | null>(null);
const [isOrgDropdownOpen, setIsOrgDropdownOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const router = useRouter();
const { t } = useTranslation();
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
const loadOrganizations = useCallback(async () => {
setIsLoadingOrganizations(true);
setOrganizationLoadError(null);
try {
const result = await getOrganizationsForSwitcherAction({ organizationId: organization.id });
if (result?.data) {
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
setOrganizations(sorted);
} else {
setOrganizationLoadError(
getFormattedErrorMessage(result) || t("common.failed_to_load_organizations")
);
}
} catch {
setOrganizationLoadError(t("common.failed_to_load_organizations"));
} finally {
setIsLoadingOrganizations(false);
}
}, [organization.id, t]);
useEffect(() => {
if (
isOrgDropdownOpen &&
organizations.length === 0 &&
!isLoadingOrganizations &&
!organizationLoadError
) {
loadOrganizations();
}
}, [
isOrgDropdownOpen,
organizations.length,
isLoadingOrganizations,
organizationLoadError,
loadOrganizations,
]);
const handleOrganizationChange = (orgId: string) => {
startTransition(() => {
setIsOrgDropdownOpen(false);
router.push(`/organizations/${orgId}/`);
});
};
const dropdownNavigation = [
{
label: t("common.documentation"),
@@ -39,6 +102,11 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
},
];
const switcherTriggerClasses =
"w-full border-t px-3 py-3 text-left transition-colors duration-200 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-500 focus-visible:ring-inset";
const switcherIconClasses =
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-slate-100 text-slate-600";
return (
<aside
className={cn(
@@ -46,45 +114,97 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
)}>
<Image src={FBLogo} width={160} height={30} alt={t("workspace.formbricks_logo")} />
<div className="flex items-center">
<div className="flex flex-col">
{/* Organization Switcher */}
<DropdownMenu onOpenChange={setIsOrgDropdownOpen}>
<DropdownMenuTrigger asChild className={switcherTriggerClasses}>
<button type="button" className="flex w-full items-center gap-3">
<span className={switcherIconClasses}>
<Building2Icon className="h-4 w-4" strokeWidth={1.5} />
</span>
<div className="grow overflow-hidden">
<p className="truncate text-sm font-bold text-slate-700">{organization.name}</p>
<p className="text-sm text-slate-500">{t("common.organization")}</p>
</div>
{isPending && <Loader2 className="h-4 w-4 animate-spin text-slate-600" strokeWidth={1.5} />}
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" sideOffset={10} alignOffset={5} align="end">
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<Building2Icon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.change_organization")}
</div>
{isLoadingOrganizations && (
<div className="flex items-center justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
{!isLoadingOrganizations && organizationLoadError && (
<div className="px-2 py-4">
<p className="mb-2 text-sm text-red-600">{organizationLoadError}</p>
<button
onClick={() => {
setOrganizationLoadError(null);
setOrganizations([]);
}}
className="text-xs text-slate-600 underline hover:text-slate-800">
{t("common.try_again")}
</button>
</div>
)}
{!isLoadingOrganizations && !organizationLoadError && (
<>
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
{organizations.map((org) => (
<DropdownMenuCheckboxItem
key={org.id}
checked={org.id === organization.id}
onClick={() => handleOrganizationChange(org.id)}
className="cursor-pointer">
{org.name}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
{isMultiOrgEnabled && (
<DropdownMenuCheckboxItem
onClick={() => setOpenCreateOrganizationModal(true)}
className="w-full cursor-pointer justify-between">
<span>{t("common.create_new_organization")}</span>
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
</DropdownMenuCheckboxItem>
)}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
{/* User Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger
asChild
id="userDropdownTrigger"
className="w-full rounded-br-xl border-t p-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
<button
type="button"
className={cn("flex w-full cursor-pointer flex-row items-center gap-3 text-left")}
aria-haspopup="menu">
<ProfileAvatar userId={user.id} />
className={cn(switcherTriggerClasses, "rounded-br-xl")}>
<button type="button" className="flex w-full items-center gap-3">
<span className={switcherIconClasses}>
<ProfileAvatar userId={user.id} />
</span>
<div className="grow overflow-hidden">
<p
title={user?.email}
className={cn(
"ph-no-capture ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700"
)}>
className="ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700">
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
</p>
<p title={organization?.name} className="truncate text-sm text-slate-500">
{organization?.name}
</p>
<p className="text-sm text-slate-500">{t("common.account")}</p>
</div>
<ChevronRightIcon className={cn("h-5 w-5 shrink-0 text-slate-700 hover:text-slate-500")} />
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
id="userDropdownInnerContentWrapper"
side="right"
sideOffset={10}
alignOffset={5}
align="end">
{/* Dropdown Items */}
<DropdownMenuContent side="right" sideOffset={10} alignOffset={5} align="end">
{dropdownNavigation.map((link) => (
<Link
key={link.href}
id={link.href}
href={link.href}
target={link.target}
rel={link.target === "_blank" ? "noopener noreferrer" : undefined}
@@ -95,8 +215,6 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
</DropdownMenuItem>
</Link>
))}
{/* Logout */}
<DropdownMenuItem
onClick={async () => {
await signOutWithAudit({
@@ -113,6 +231,7 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
</DropdownMenuContent>
</DropdownMenu>
</div>
<CreateOrganizationModal open={openCreateOrganizationModal} setOpen={setOpenCreateOrganizationModal} />
</aside>
);
@@ -3,7 +3,6 @@ import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organiza
import { WorkspaceAndOrgSwitch } from "@/app/(app)/workspaces/[workspaceId]/components/workspace-and-org-switch";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
import { getUser } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
@@ -26,12 +25,11 @@ const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
const { isMember, isBilling } = getAccessFlags(membership?.role);
const isMembershipPending = membership?.role === undefined;
return (
<div className="flex min-h-full min-w-full flex-row">
<LandingSidebar user={user} organization={organization} />
<LandingSidebar user={user} organization={organization} isMultiOrgEnabled={isMultiOrgEnabled} />
<div className="flex-1">
<div className="flex h-full flex-col">
<div className="p-6">
@@ -45,8 +43,6 @@ const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
isLicenseActive={false}
isOwnerOrManager={false}
isAccessControlAllowed={false}
isMember={isMember}
isBilling={isBilling}
isMembershipPending={isMembershipPending}
/>
</div>
@@ -2,6 +2,7 @@ import { PictureInPicture2Icon, SendIcon, XIcon } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
import { capturePostHogEvent } from "@/lib/posthog";
import { getUserWorkspaces } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
@@ -41,6 +42,16 @@ const Page = async (props: ChannelPageProps) => {
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
capturePostHogEvent(
session.user.id,
"organization_mode_selected",
{
organization_id: params.organizationId,
mode: "surveys",
},
{ organizationId: params.organizationId }
);
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header
@@ -2,6 +2,7 @@ import { HeartIcon, ListTodoIcon, XIcon } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
import { getPostHogFeatureFlag } from "@/lib/posthog/get-feature-flag";
import { getUserWorkspaces } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
@@ -23,6 +24,13 @@ const Page = async (props: ModePageProps) => {
return redirect(`/auth/login`);
}
const experimentVariant =
(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`);
}
const t = await getTranslate();
const channelOptions = [
{
@@ -1,22 +1,17 @@
import { getTranslate } from "@/lingodotdev/server";
import { SelectPlanCard } from "@/modules/ee/billing/components/select-plan-card";
import { Header } from "@/modules/ui/components/header";
import { type TPlanVariant } from "@/modules/ee/billing/lib/select-plan-variants";
interface SelectPlanOnboardingProps {
organizationId: string;
variant: TPlanVariant;
}
export const SelectPlanOnboarding = async ({ organizationId }: SelectPlanOnboardingProps) => {
const t = await getTranslate();
export const SelectPlanOnboarding = ({ organizationId, variant }: Readonly<SelectPlanOnboardingProps>) => {
const nextUrl = `/organizations/${organizationId}/workspaces/new/mode`;
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-8">
<Header
title={t("workspace.settings.billing.select_plan_header_title")}
subtitle={t("workspace.settings.billing.select_plan_header_subtitle")}
/>
<SelectPlanCard nextUrl={nextUrl} organizationId={organizationId} />
<div className="flex min-h-full min-w-full flex-col items-center justify-center">
<SelectPlanCard nextUrl={nextUrl} organizationId={organizationId} variant={variant} />
</div>
);
};
@@ -1,11 +1,14 @@
import { redirect } from "next/navigation";
import { TCloudBillingPlan } from "@formbricks/types/organizations";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getPostHogFeatureFlag } from "@/lib/posthog/get-feature-flag";
import { getOrganizationBillingWithReadThroughSync } from "@/modules/ee/billing/lib/organization-billing";
import { PLAN_VARIANTS, type TPlanVariant } from "@/modules/ee/billing/lib/select-plan-variants";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { SelectPlanOnboarding } from "./components/select-plan-onboarding";
const PAID_PLANS = new Set<TCloudBillingPlan>(["pro", "scale", "custom"]);
const VALID_VARIANTS = new Set<TPlanVariant>(PLAN_VARIANTS);
interface PlanPageProps {
params: Promise<{
@@ -36,7 +39,24 @@ const Page = async (props: PlanPageProps) => {
return redirect(`/organizations/${params.organizationId}/workspaces/new/mode`);
}
return <SelectPlanOnboarding organizationId={params.organizationId} />;
let variant: TPlanVariant = "control";
const flagValue = await getPostHogFeatureFlag(
session.user.id,
"a-b_onboarding_trial-conversion-screen-copy",
{
organizationId: params.organizationId,
}
);
if (typeof flagValue === "string" && VALID_VARIANTS.has(flagValue as TPlanVariant)) {
variant = flagValue as TPlanVariant;
}
const selectPlanOnboardingProps = {
organizationId: params.organizationId,
variant,
};
return <SelectPlanOnboarding {...selectPlanOnboardingProps} />;
};
export default Page;
@@ -18,6 +18,7 @@ import { createWorkspaceAction } from "@/app/(app)/workspaces/[workspaceId]/acti
import { previewSurvey } from "@/app/lib/templates";
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
import { buildStylingFromBrandColor } from "@/lib/styling/constants";
import { toJsWorkspaceStateSurvey } from "@/lib/survey/client-utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { CreateTeamModal } from "@/modules/ee/teams/team-list/components/create-team-modal";
import { TOrganizationTeam } from "@/modules/ee/teams/workspace-teams/types/team";
@@ -237,7 +238,7 @@ export const WorkspaceSettings = ({
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={previewSurvey(workspaceName || t("common.my_product"), t)}
survey={toJsWorkspaceStateSurvey(previewSurvey(workspaceName || t("common.my_product"), t))}
styling={previewStyling}
isBrandingEnabled={false}
languageCode="default"
@@ -11,12 +11,16 @@ import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboardin
import { WorkspaceSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/WorkspaceSettings";
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { capturePostHogEvent } from "@/lib/posthog";
import { getPostHogFeatureFlag } from "@/lib/posthog/get-feature-flag";
import { buildStylingFromBrandColor } from "@/lib/styling/constants";
import { getUserWorkspaces } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { Header } from "@/modules/ui/components/header";
import { createWorkspace } from "@/modules/workspaces/settings/lib/workspace";
interface WorkspaceSettingsPageProps {
params: Promise<{
@@ -43,8 +47,29 @@ const Page = async (props: WorkspaceSettingsPageProps) => {
const channel = searchParams.channel ?? null;
const industry = searchParams.industry ?? null;
const mode = searchParams.mode ?? "surveys";
const experimentVariant =
(await getPostHogFeatureFlag(session.user.id, "a-b_onboarding_skip-theme-screen")) || "control";
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
if (experimentVariant === "remove-theme") {
const existing = workspaces.find((w) => w.name === organization.name);
const workspace =
existing ??
(await createWorkspace(params.organizationId, {
name: organization.name,
styling: buildStylingFromBrandColor(DEFAULT_BRAND_COLOR),
config: { channel, industry },
}));
if (channel === "app" || channel === "website") {
return redirect(`/workspaces/${workspace.id}/connect`);
} else if (channel === "link") {
return redirect(`/workspaces/${workspace.id}/surveys`);
}
return redirect(`/workspaces/${workspace.id}/xm-templates`);
}
const organizationTeams = await getTeamsByOrganizationId(params.organizationId);
const isAccessControlAllowed = await getAccessControlPermission(organization.id);
@@ -55,6 +80,18 @@ const Page = async (props: WorkspaceSettingsPageProps) => {
const publicDomain = getPublicDomain();
if (searchParams.mode === "cx") {
capturePostHogEvent(
session.user.id,
"organization_mode_selected",
{
organization_id: params.organizationId,
mode: "cx",
},
{ organizationId: params.organizationId }
);
}
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header
@@ -60,8 +60,8 @@ const mockTemplate: TXMTemplate = {
],
styling: {
brandColor: { light: "#0000FF" },
questionColor: { light: "#00FF00" },
inputColor: { light: "#FF0000" },
elementHeadlineColor: { light: "#00FF00" },
inputBgColor: { light: "#FF0000" },
},
};
@@ -39,7 +39,10 @@ export const ConfirmationPage = () => {
</p>
</div>
<Button asChild className="w-full justify-center">
<Link href={resolvedWorkspaceId ? `/workspaces/${resolvedWorkspaceId}/settings/billing` : "/"}>
<Link
href={
resolvedWorkspaceId ? `/workspaces/${resolvedWorkspaceId}/settings/organization/billing` : "/"
}>
{t("billing_confirmation.back_to_billing_overview")}
</Link>
</Button>
@@ -1 +1,8 @@
export { WorkspaceSourcesPage as default } from "@/modules/workspaces/settings/sources/page";
import { redirect } from "next/navigation";
export default async function FeedbackSourcesRedirect(
props: Readonly<{ params: Promise<{ workspaceId: string }> }>
) {
const { workspaceId } = await props.params;
redirect(`/workspaces/${workspaceId}/settings/workspace/feedback-sources`);
}
@@ -10,6 +10,7 @@ import {
import { ZWorkspaceUpdateInput } from "@formbricks/types/workspace";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getOrganization } from "@/lib/organization/service";
import { capturePostHogEvent, groupIdentifyPostHog } from "@/lib/posthog";
import { updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
@@ -23,13 +24,9 @@ import { createWorkspace } from "@/modules/workspaces/settings/lib/workspace";
import { getOrganizationsByUserId } from "./lib/organization";
import { getWorkspacesByUserId } from "./lib/workspace";
const ZCreateWorkspaceInput = ZWorkspaceUpdateInput.extend({
feedbackRecordDirectoryId: ZId.optional(),
});
const ZCreateWorkspaceAction = z.object({
organizationId: ZId,
data: ZCreateWorkspaceInput,
data: ZWorkspaceUpdateInput,
});
export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCreateWorkspaceAction).action(
@@ -44,7 +41,7 @@ export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCrea
access: [
{
data: parsedInput.data,
schema: ZCreateWorkspaceInput,
schema: ZWorkspaceUpdateInput,
type: "organization",
roles: ["owner", "manager"],
},
@@ -84,6 +81,19 @@ export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCrea
notificationSettings: updatedNotificationSettings,
});
groupIdentifyPostHog("workspace", workspace.id, { name: workspace.name });
capturePostHogEvent(
user.id,
"workspace_created",
{
organization_id: organizationId,
workspace_id: workspace.id,
name: workspace.name,
},
{ organizationId, workspaceId: workspace.id }
);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.workspaceId = workspace.id;
ctx.auditLoggingCtx.newObject = workspace;
@@ -121,7 +131,7 @@ const ZGetWorkspacesForSwitcherAction = z.object({
});
/**
* Fetches projects list for switcher dropdown.
* Fetches workspaces list for switcher dropdown.
* Called on-demand when user opens the workspace switcher.
*/
export const getWorkspacesForSwitcherAction = authenticatedActionClient
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,450 @@
"use client";
import {
BellIcon,
BlocksIcon,
BrushIcon,
Building2Icon,
ChevronDownIcon,
CreditCardIcon,
FoldersIcon,
GlobeIcon,
KeyIcon,
LanguagesIcon,
ListChecksIcon,
Loader2,
ShapesIcon,
ShieldIcon,
TagIcon,
UnplugIcon,
UserCircleIcon,
UsersIcon,
} from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useTranslation } from "react-i18next";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { cn } from "@/lib/cn";
import { getAccessFlags } from "@/lib/membership/utils";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
interface SettingsSidebarContentProps {
workspaceId: string;
workspaceName: string;
organizationId: string;
organizationName: string;
membershipRole?: TOrganizationRole;
isFormbricksCloud: boolean;
isCollapsed: boolean;
isTextVisible: boolean;
// Workspace switcher
workspaces: { id: string; name: string }[];
isLoadingWorkspaces: boolean;
onWorkspaceChange: (id: string) => void;
onWorkspaceDropdownOpen: () => void;
// Organization switcher
organizations: { id: string; name: string }[];
isLoadingOrganizations: boolean;
onOrganizationChange: (id: string) => void;
onOrganizationDropdownOpen: () => void;
}
interface NavItem {
id: string;
label: string;
href: string;
icon: React.ReactNode;
hidden?: boolean;
disabled?: boolean;
}
const SettingsNavLink = ({
item,
isActive,
isCollapsed,
isTextVisible,
disabledMessage,
}: {
item: NavItem;
isActive: boolean;
isCollapsed: boolean;
isTextVisible: boolean;
disabledMessage?: string;
}) => {
const activeClass = "bg-slate-50 border-r-4 border-brand-dark font-semibold text-slate-900";
const inactiveClass =
"hover:bg-slate-50 border-r-4 border-transparent hover:border-slate-300 transition-all duration-150 ease-in-out";
const disabledClass = "cursor-not-allowed border-r-4 border-transparent text-slate-400";
const isDisabled = item.disabled;
const getStateClass = () => {
if (isDisabled) return disabledClass;
return isActive ? activeClass : inactiveClass;
};
if (isCollapsed) {
return (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<li className={cn("rounded-l-md py-1.5 pl-2 text-sm", getStateClass())}>
{isDisabled ? (
<div className="flex items-center">{item.icon}</div>
) : (
<Link href={item.href} className="flex items-center text-slate-600 hover:text-slate-900">
{item.icon}
</Link>
)}
</li>
</TooltipTrigger>
<TooltipContent side="right">
{isDisabled ? disabledMessage || item.label : item.label}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
if (isDisabled) {
return (
<li className={cn("rounded-l-md py-1.5 pl-8 text-sm", disabledClass)}>
<Popover>
<PopoverTrigger asChild>
<div className="flex items-center">
{item.icon}
<span
className={cn(
"ml-2 transition-opacity duration-100",
isTextVisible ? "opacity-0" : "opacity-100"
)}>
{item.label}
</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
{disabledMessage || item.label}
</PopoverContent>
</Popover>
</li>
);
}
return (
<li
className={cn(
"rounded-l-md py-1.5 pl-8 text-sm",
isActive ? activeClass : inactiveClass,
"text-slate-600 hover:text-slate-900"
)}>
<Link href={item.href} className="flex items-center">
{item.icon}
<span
className={cn("ml-2 transition-opacity duration-100", isTextVisible ? "opacity-0" : "opacity-100")}>
{item.label}
</span>
</Link>
</li>
);
};
const SectionHeader = ({
icon,
label,
isCollapsed,
isTextVisible,
switcherName,
switcherItems,
isLoadingSwitcher,
currentId,
onSwitcherChange,
onSwitcherOpen,
}: {
icon: React.ReactNode;
label: string;
isCollapsed: boolean;
isTextVisible: boolean;
switcherName?: string;
switcherItems?: { id: string; name: string }[];
isLoadingSwitcher?: boolean;
currentId?: string;
onSwitcherChange?: (id: string) => void;
onSwitcherOpen?: () => void;
}) => {
if (isCollapsed) {
return <div className="mb-1 mt-3 flex justify-center px-2 text-slate-400">{icon}</div>;
}
return (
<div
className={cn(
"mb-1 mt-4 flex min-w-0 items-center gap-2 px-3",
isTextVisible ? "opacity-0" : "opacity-100"
)}>
<span className="text-slate-500">{icon}</span>
<span className="shrink-0 text-xs font-semibold uppercase tracking-wider text-slate-500">{label}</span>
{switcherName && switcherItems && onSwitcherChange && (
<DropdownMenu onOpenChange={(open) => open && onSwitcherOpen?.()}>
<DropdownMenuTrigger className="ml-auto flex min-w-0 max-w-[50%] items-center gap-1 rounded-md border border-slate-200 px-2 py-0.5 text-xs text-slate-600 hover:bg-slate-50">
<span className="truncate">{switcherName}</span>
<ChevronDownIcon className="h-3 w-3" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="max-h-[300px]">
{isLoadingSwitcher ? (
<div className="flex items-center justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : (
<DropdownMenuGroup className="overflow-y-auto">
{switcherItems.map((item) => (
<DropdownMenuCheckboxItem
key={item.id}
checked={item.id === currentId}
onClick={() => onSwitcherChange(item.id)}
className="cursor-pointer text-sm">
{item.name}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
};
export const SettingsSidebarContent = ({
workspaceId,
workspaceName,
organizationId,
organizationName,
membershipRole,
isFormbricksCloud,
isCollapsed,
isTextVisible,
workspaces,
isLoadingWorkspaces,
onWorkspaceChange,
onWorkspaceDropdownOpen,
organizations,
isLoadingOrganizations,
onOrganizationChange,
onOrganizationDropdownOpen,
}: SettingsSidebarContentProps) => {
const pathname = usePathname();
const { t } = useTranslation();
const { isMember, isBilling, isOwner, isManager } = getAccessFlags(membershipRole);
const isOwnerOrManager = isOwner || isManager;
const iconClassName = "h-4 w-4 shrink-0";
const basePath = `/workspaces/${workspaceId}/settings`;
const workspaceItems: NavItem[] = [
{
id: "general",
label: t("common.general"),
href: `${basePath}/workspace/general`,
icon: <FoldersIcon className={iconClassName} />,
disabled: isBilling,
},
{
id: "teams",
label: t("common.team_access"),
href: `${basePath}/workspace/teams`,
icon: <UsersIcon className={iconClassName} />,
disabled: isBilling,
},
{
id: "languages",
label: t("common.survey_languages"),
href: `${basePath}/workspace/languages`,
icon: <LanguagesIcon className={iconClassName} />,
disabled: isBilling,
},
{
id: "app-connection",
label: t("common.connect_your_app"),
href: `${basePath}/workspace/app-connection`,
icon: <UnplugIcon className={iconClassName} />,
disabled: isBilling,
},
{
id: "feedback-sources",
label: t("workspace.unify.feedback_sources"),
href: `${basePath}/workspace/feedback-sources`,
icon: <ShapesIcon className={iconClassName} />,
disabled: isBilling,
},
{
id: "integrations",
label: t("common.integrations"),
href: `${basePath}/workspace/integrations`,
icon: <BlocksIcon className={iconClassName} />,
disabled: isBilling,
},
{
id: "look",
label: t("common.appearance"),
href: `${basePath}/workspace/look`,
icon: <BrushIcon className={iconClassName} />,
disabled: isBilling,
},
{
id: "user-actions",
label: t("common.user_actions"),
href: `${basePath}/workspace/user-actions`,
icon: <ListChecksIcon className={iconClassName} />,
disabled: isBilling,
},
{
id: "tags",
label: t("common.tags"),
href: `${basePath}/workspace/tags`,
icon: <TagIcon className={iconClassName} />,
disabled: isBilling,
},
];
const organizationItems: NavItem[] = [
{
id: "org-general",
label: t("common.general"),
href: `${basePath}/organization/general`,
icon: <Building2Icon className={iconClassName} />,
disabled: isBilling,
},
{
id: "org-teams",
label: t("common.teams"),
href: `${basePath}/organization/teams`,
icon: <UsersIcon className={iconClassName} />,
disabled: isBilling,
},
{
id: "org-feedback-directories",
label: t("workspace.settings.feedback_directories.nav_label"),
href: `${basePath}/organization/feedback-directories`,
icon: <FoldersIcon className={iconClassName} />,
hidden: isMember,
},
{
id: "org-api-keys",
label: t("common.api_keys"),
href: `${basePath}/organization/api-keys`,
icon: <KeyIcon className={iconClassName} />,
hidden: !isOwnerOrManager,
},
{
id: "org-domain",
label: t("common.domain"),
href: `${basePath}/organization/domain`,
icon: <GlobeIcon className={iconClassName} />,
hidden: isFormbricksCloud,
},
{
id: "org-billing",
label: t("common.billing"),
href: `${basePath}/organization/billing`,
icon: <CreditCardIcon className={iconClassName} />,
hidden: !isFormbricksCloud,
},
{
id: "org-enterprise",
label: t("common.enterprise_license"),
href: `${basePath}/organization/enterprise`,
icon: <ShieldIcon className={iconClassName} />,
hidden: isFormbricksCloud,
disabled: isMember || isBilling,
},
];
const accountItems: NavItem[] = [
{
id: "profile",
label: t("common.your_profile"),
href: `${basePath}/account/profile`,
icon: <UserCircleIcon className={iconClassName} />,
},
{
id: "notifications",
label: t("common.notifications"),
href: `${basePath}/account/notifications`,
icon: <BellIcon className={iconClassName} />,
},
];
const disabledMessage = t("common.you_are_not_authorized_to_perform_this_action");
const renderSection = (items: NavItem[]) => {
const visibleItems = items.filter((item) => !item.hidden);
return (
<ul className="space-y-0.5">
{visibleItems.map((item) => (
<SettingsNavLink
key={item.id}
item={item}
isActive={pathname.includes(item.href)}
isCollapsed={isCollapsed}
isTextVisible={isTextVisible}
disabledMessage={item.disabled ? disabledMessage : undefined}
/>
))}
</ul>
);
};
return (
<div className="flex flex-col overflow-y-auto">
<div>
<SectionHeader
icon={<FoldersIcon className="h-4 w-4" />}
label={t("common.workspace")}
isCollapsed={isCollapsed}
isTextVisible={isTextVisible}
switcherName={workspaceName}
switcherItems={workspaces}
isLoadingSwitcher={isLoadingWorkspaces}
currentId={workspaceId}
onSwitcherChange={onWorkspaceChange}
onSwitcherOpen={onWorkspaceDropdownOpen}
/>
{renderSection(workspaceItems)}
</div>
<div>
<SectionHeader
icon={<Building2Icon className="h-4 w-4" />}
label={t("common.organization")}
isCollapsed={isCollapsed}
isTextVisible={isTextVisible}
switcherName={organizationName}
switcherItems={organizations}
isLoadingSwitcher={isLoadingOrganizations}
currentId={organizationId}
onSwitcherChange={onOrganizationChange}
onSwitcherOpen={onOrganizationDropdownOpen}
/>
{renderSection(organizationItems)}
</div>
<div>
<SectionHeader
icon={<UserCircleIcon className="h-4 w-4" />}
label={t("common.account")}
isCollapsed={isCollapsed}
isTextVisible={isTextVisible}
/>
{renderSection(accountItems)}
</div>
</div>
);
};
@@ -3,7 +3,6 @@
import { TOrganizationRole } from "@formbricks/types/memberships";
import { WorkspaceAndOrgSwitch } from "@/app/(app)/workspaces/[workspaceId]/components/workspace-and-org-switch";
import { useWorkspaceContext } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
import { getAccessFlags } from "@/lib/membership/utils";
interface TopControlBarProps {
currentOrganizationId: string;
@@ -26,7 +25,6 @@ export const TopControlBar = ({
isAccessControlAllowed,
membershipRole,
}: TopControlBarProps) => {
const { isMember, isBilling } = getAccessFlags(membershipRole);
const { workspace } = useWorkspaceContext();
const isMembershipPending = membershipRole === undefined;
@@ -42,8 +40,6 @@ export const TopControlBar = ({
isFormbricksCloud={isFormbricksCloud}
isLicenseActive={isLicenseActive}
isOwnerOrManager={isOwnerOrManager}
isMember={isMember}
isBilling={isBilling}
isMembershipPending={isMembershipPending}
isAccessControlAllowed={isAccessControlAllowed}
/>
@@ -4,6 +4,7 @@ import { TopControlBar } from "@/app/(app)/workspaces/[workspaceId]/components/T
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getAccessFlags } from "@/lib/membership/utils";
import { getPostHogFeatureFlag } from "@/lib/posthog/get-feature-flag";
import { getTranslate } from "@/lingodotdev/server";
import { getOrganizationWorkspacesLimit } from "@/modules/ee/license-check/lib/utils";
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";
@@ -37,6 +38,7 @@ export const WorkspaceLayout = async ({ layoutData, children }: WorkspaceLayoutP
const { features, lastChecked, isPendingDowngrade, active, status } = license;
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
const organizationWorkspacesLimit = await getOrganizationWorkspacesLimit(organization.id);
const newTrialBannerVariant = await getPostHogFeatureFlag(user.id, "a-b_navigation_rich-trial-banner");
const isOwnerOrManager = isOwner || isManager;
// Validate that workspace permission exists for members
@@ -71,6 +73,8 @@ export const WorkspaceLayout = async ({ layoutData, children }: WorkspaceLayoutP
organizationWorkspacesLimit={organizationWorkspacesLimit}
isLicenseActive={active}
isAccessControlAllowed={isAccessControlAllowed}
responseCount={responseCount}
newTrialBannerVariant={newTrialBannerVariant}
/>
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
<TopControlBar
@@ -9,7 +9,7 @@ import {
PlusIcon,
SettingsIcon,
} from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import { useRouter } from "next/navigation";
import { useEffect, useState, useTransition } from "react";
import { useTranslation } from "react-i18next";
import { logger } from "@formbricks/logger";
@@ -25,7 +25,6 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
import { useOrganization, useWorkspace } from "../context/workspace-context";
interface OrganizationBreadcrumbProps {
@@ -33,37 +32,17 @@ interface OrganizationBreadcrumbProps {
currentOrganizationName?: string; // Optional: pass directly if context not available
isMultiOrgEnabled: boolean;
currentWorkspaceId?: string;
isFormbricksCloud: boolean;
isMember: boolean;
isOwnerOrManager: boolean;
isMembershipPending: boolean;
}
const isActiveOrganizationSetting = (pathname: string, settingId: string): boolean => {
// Match /settings/{settingId} or /settings/{settingId}/... but exclude account settings
// Exclude paths with /(account)/
if (pathname.includes("/(account)/")) {
return false;
}
// Check if path matches /settings/{settingId} (with optional trailing path)
const pattern = new RegExp(`/settings/${settingId}(?:/|$)`);
return pattern.test(pathname);
};
export const OrganizationBreadcrumb = ({
currentOrganizationId,
currentOrganizationName,
isMultiOrgEnabled,
currentWorkspaceId,
isFormbricksCloud,
isMember,
isOwnerOrManager,
isMembershipPending,
}: OrganizationBreadcrumbProps) => {
const { t } = useTranslation();
const [isOrganizationDropdownOpen, setIsOrganizationDropdownOpen] = useState(false);
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState(false);
const pathname = usePathname();
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(false);
@@ -120,7 +99,7 @@ export const OrganizationBreadcrumb = ({
startTransition(() => {
setIsOrganizationDropdownOpen(false);
if (organizationId === currentOrganizationId && currentWorkspaceId) {
router.push(`/workspaces/${currentWorkspaceId}/settings/general`);
router.push(`/workspaces/${currentWorkspaceId}/settings/organization/general`);
return;
}
router.push(`/organizations/${organizationId}/`);
@@ -137,59 +116,6 @@ export const OrganizationBreadcrumb = ({
});
};
const organizationSettings = [
{
id: "general",
label: t("common.general"),
href: `${workspaceBasePath}/settings/general`,
},
{
id: "teams",
label: t("common.members_and_teams"),
href: `${workspaceBasePath}/settings/teams`,
},
{
id: "api-keys",
label: t("common.api_keys"),
href: `${workspaceBasePath}/settings/api-keys`,
disabled: isMembershipPending || !isOwnerOrManager,
disabledMessage: isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action"),
},
{
id: "domain",
label: t("common.domain"),
href: `${workspaceBasePath}/settings/domain`,
hidden: isFormbricksCloud,
},
{
id: "billing",
label: t("common.billing"),
href: `${workspaceBasePath}/settings/billing`,
hidden: !isFormbricksCloud,
},
{
id: "enterprise",
label: t("common.enterprise_license"),
href: `${workspaceBasePath}/settings/enterprise`,
hidden: isFormbricksCloud || isMember,
disabled: isMembershipPending || isMember,
disabledMessage: isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action"),
},
{
id: "feedback-record-directories",
label: t("workspace.settings.feedback_record_directories.title"),
href: `${workspaceBasePath}/settings/feedback-record-directories`,
disabled: isMembershipPending || isMember,
disabledMessage: isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action"),
},
];
return (
<BreadcrumbItem isActive={isOrganizationDropdownOpen}>
<DropdownMenu onOpenChange={setIsOrganizationDropdownOpen}>
@@ -259,42 +185,15 @@ export const OrganizationBreadcrumb = ({
</>
)}
{currentWorkspaceId && (
<div>
<>
{showOrganizationDropdown && <DropdownMenuSeparator />}
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<SettingsIcon className="mr-2 inline h-4 w-4" />
{t("common.organization_settings")}
</div>
{organizationSettings.map((setting) => {
return setting.hidden ? null : (
<div key={setting.id}>
{setting.disabled ? (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
aria-disabled="true"
className="relative flex w-full cursor-not-allowed select-none items-center rounded-lg py-1.5 pl-8 pr-2 text-sm font-medium text-slate-400">
{setting.label}
</button>
</PopoverTrigger>
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
{setting.disabledMessage}
</PopoverContent>
</Popover>
) : (
<DropdownMenuCheckboxItem
checked={isActiveOrganizationSetting(pathname, setting.id)}
onClick={() => handleSettingChange(setting.href)}
className="cursor-pointer">
{setting.label}
</DropdownMenuCheckboxItem>
)}
</div>
);
})}
</div>
<DropdownMenuCheckboxItem
onClick={() => handleSettingChange(`${workspaceBasePath}/settings/organization/general`)}
className="cursor-pointer">
<SettingsIcon className="mr-2 h-4 w-4" />
{t("common.settings")}
</DropdownMenuCheckboxItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
@@ -14,9 +14,7 @@ interface WorkspaceAndOrgSwitchProps {
isFormbricksCloud: boolean;
isLicenseActive: boolean;
isOwnerOrManager: boolean;
isMember: boolean;
isAccessControlAllowed: boolean;
isBilling: boolean;
isMembershipPending: boolean;
}
@@ -31,8 +29,6 @@ export const WorkspaceAndOrgSwitch = ({
isLicenseActive,
isOwnerOrManager,
isAccessControlAllowed,
isMember,
isBilling,
isMembershipPending,
}: WorkspaceAndOrgSwitchProps) => {
return (
@@ -43,10 +39,6 @@ export const WorkspaceAndOrgSwitch = ({
currentOrganizationName={currentOrganizationName}
currentWorkspaceId={currentWorkspaceId}
isMultiOrgEnabled={isMultiOrgEnabled}
isFormbricksCloud={isFormbricksCloud}
isMember={isMember}
isOwnerOrManager={isOwnerOrManager}
isMembershipPending={isMembershipPending}
/>
{currentWorkspaceId && (
<WorkspaceBreadcrumb
@@ -59,7 +51,6 @@ export const WorkspaceAndOrgSwitch = ({
isLicenseActive={isLicenseActive}
isAccessControlAllowed={isAccessControlAllowed}
isEnvironmentBreadcrumbVisible={false}
isBilling={isBilling}
isMembershipPending={isMembershipPending}
/>
)}
@@ -2,7 +2,7 @@
import * as Sentry from "@sentry/nextjs";
import { ChevronDownIcon, ChevronRightIcon, CogIcon, FoldersIcon, Loader2, PlusIcon } from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import { useRouter } from "next/navigation";
import { useEffect, useState, useTransition } from "react";
import { useTranslation } from "react-i18next";
import { logger } from "@formbricks/logger";
@@ -33,20 +33,9 @@ interface WorkspaceBreadcrumbProps {
currentOrganizationId: string;
isAccessControlAllowed: boolean;
isEnvironmentBreadcrumbVisible: boolean;
isBilling: boolean;
isMembershipPending: boolean;
}
const isActiveWorkspaceSetting = (pathname: string, settingId: string): boolean => {
// Match /{settingId} or /{settingId}/... but exclude settings paths
if (pathname.includes("/settings/")) {
return false;
}
// Check if path matches /workspaces/{id}/{settingId} (with optional trailing path)
const pattern = new RegExp(`/workspaces/[^/]+/${settingId}(?:/|$)`);
return pattern.test(pathname);
};
export const WorkspaceBreadcrumb = ({
currentWorkspaceId,
currentWorkspaceName,
@@ -57,7 +46,6 @@ export const WorkspaceBreadcrumb = ({
currentOrganizationId,
isAccessControlAllowed,
isEnvironmentBreadcrumbVisible,
isBilling,
isMembershipPending,
}: WorkspaceBreadcrumbProps) => {
const { t } = useTranslation();
@@ -69,7 +57,6 @@ export const WorkspaceBreadcrumb = ({
const [workspaces, setWorkspaces] = useState<{ id: string; name: string }[]>([]);
const [loadError, setLoadError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const pathname = usePathname();
// Get current workspace name from context OR prop
// Context is preferred, but prop is fallback for pages without EnvironmentContextWrapper
@@ -102,59 +89,6 @@ export const WorkspaceBreadcrumb = ({
}
}, [isWorkspaceDropdownOpen, currentOrganizationId, workspaces.length, isLoadingWorkspaces, loadError, t]);
const workspaceSettings = [
{
id: "general",
label: t("common.general"),
href: `${workspaceBasePath}/general`,
},
{
id: "look",
label: t("common.look_and_feel"),
href: `${workspaceBasePath}/look`,
},
{
id: "app-connection",
label: t("common.website_and_app_connection"),
href: `${workspaceBasePath}/app-connection`,
},
{
id: "feedback-sources",
label: t("workspace.unify.feedback_sources"),
href: `${workspaceBasePath}/feedback-sources`,
},
{
id: "integrations",
label: t("common.integrations"),
href: `${workspaceBasePath}/integrations`,
},
{
id: "teams",
label: t("common.team_access"),
href: `${workspaceBasePath}/teams`,
},
{
id: "languages",
label: t("common.survey_languages"),
href: `${workspaceBasePath}/languages`,
},
{
id: "tags",
label: t("common.tags"),
href: `${workspaceBasePath}/tags`,
},
{
id: "unify",
label: t("common.unify"),
href: `${workspaceBasePath}/workspace/unify`,
},
];
const areWorkspaceSettingsDisabled = isMembershipPending || isBilling;
const workspaceSettingsDisabledMessage = isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action");
if (!currentWorkspace) {
const errorMessage = `Workspace not found for workspace id: ${currentWorkspaceId}`;
logger.error(errorMessage);
@@ -181,9 +115,9 @@ export const WorkspaceBreadcrumb = ({
setOpenCreateWorkspaceModal(true);
};
const handleWorkspaceSettingsNavigation = (settingId: string) => {
const handleWorkspaceSettingsNavigation = (href: string) => {
startTransition(() => {
router.push(`${workspaceBasePath}/${settingId}`);
router.push(href);
});
};
@@ -192,7 +126,7 @@ export const WorkspaceBreadcrumb = ({
return [
{
text: t("workspace.settings.billing.upgrade"),
href: `${workspaceBasePath}/settings/billing`,
href: `${workspaceBasePath}/settings/organization/billing`,
},
{
text: t("common.cancel"),
@@ -205,7 +139,7 @@ export const WorkspaceBreadcrumb = ({
{
text: t("workspace.settings.billing.upgrade"),
href: isLicenseActive
? `${workspaceBasePath}/settings/enterprise`
? `${workspaceBasePath}/settings/organization/enterprise`
: "https://formbricks.com/upgrade-self-hosted-license",
},
{
@@ -296,39 +230,15 @@ export const WorkspaceBreadcrumb = ({
)}
</>
)}
<DropdownMenuGroup>
<DropdownMenuSeparator />
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<CogIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.workspace_configuration")}
</div>
{workspaceSettings.map((setting) => (
<div key={setting.id}>
{areWorkspaceSettingsDisabled ? (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
aria-disabled="true"
className="relative flex w-full cursor-not-allowed select-none items-center rounded-lg py-1.5 pl-8 pr-2 text-sm font-medium text-slate-400">
{setting.label}
</button>
</PopoverTrigger>
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
{workspaceSettingsDisabledMessage}
</PopoverContent>
</Popover>
) : (
<DropdownMenuCheckboxItem
checked={isActiveWorkspaceSetting(pathname, setting.id)}
onClick={() => handleWorkspaceSettingsNavigation(setting.id)}
className="cursor-pointer">
{setting.label}
</DropdownMenuCheckboxItem>
)}
</div>
))}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
onClick={() =>
handleWorkspaceSettingsNavigation(`${workspaceBasePath}/settings/workspace/general`)
}
className="cursor-pointer">
<CogIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />
{t("common.settings")}
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Modals */}
@@ -2,6 +2,8 @@ import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { WorkspaceLayout as WorkspaceLayoutComponent } from "@/app/(app)/workspaces/[workspaceId]/components/WorkspaceLayout";
import { WorkspaceContextWrapper } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
import { PostHogGroupIdentify } from "@/app/posthog/PostHogGroupIdentify";
import { POSTHOG_KEY } from "@/lib/constants";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getWorkspaceLayoutData } from "@/modules/workspaces/lib/utils";
import WorkspaceStorageHandler from "./components/WorkspaceStorageHandler";
@@ -23,6 +25,14 @@ const WorkspaceLayout = async (props: {
return (
<>
<WorkspaceStorageHandler workspaceId={params.workspaceId} />
{POSTHOG_KEY && (
<PostHogGroupIdentify
organizationId={layoutData.organization.id}
organizationName={layoutData.organization.name}
workspaceId={layoutData.workspace.id}
workspaceName={layoutData.workspace.name}
/>
)}
<WorkspaceContextWrapper workspace={layoutData.workspace} organization={layoutData.organization}>
<WorkspaceLayoutComponent layoutData={layoutData}>{children}</WorkspaceLayoutComponent>
</WorkspaceContextWrapper>
@@ -1,39 +0,0 @@
import { getServerSession } from "next-auth";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganization } from "@/lib/organization/service";
import { getWorkspace } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
const AccountSettingsLayout = async (props: {
params: Promise<{ workspaceId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params;
const { children } = props;
const t = await getTranslate();
const [workspace, session] = await Promise.all([
getWorkspace(params.workspaceId),
getServerSession(authOptions),
]);
if (!workspace) {
throw new ResourceNotFoundError(t("common.workspace"), null);
}
const organization = await getOrganization(workspace.organizationId);
if (!organization) {
throw new ResourceNotFoundError(t("common.organization"), null);
}
if (!session) {
throw new AuthenticationError(t("common.not_authenticated"));
}
return <>{children}</>;
};
export default AccountSettingsLayout;
@@ -1,133 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { verifyPassword as mockVerifyPasswordImported } from "@/modules/auth/lib/utils";
import { getIsEmailUnique, verifyUserPassword } from "./user";
vi.mock("@/modules/auth/lib/utils", () => ({
verifyPassword: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
user: {
findUnique: vi.fn(),
},
},
}));
const mockPrismaUserFindUnique = vi.mocked(prisma.user.findUnique);
const mockVerifyPasswordUtil = vi.mocked(mockVerifyPasswordImported);
describe("User Library Tests", () => {
beforeEach(() => {
vi.resetAllMocks();
});
describe("verifyUserPassword", () => {
const userId = "test-user-id";
const password = "test-password";
test("should return true for correct password", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
password: "hashed-password",
identityProvider: "email",
} as any);
mockVerifyPasswordUtil.mockResolvedValue(true);
const result = await verifyUserPassword(userId, password);
expect(result).toBe(true);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).toHaveBeenCalledWith(password, "hashed-password");
});
test("should return false for incorrect password", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
password: "hashed-password",
identityProvider: "email",
} as any);
mockVerifyPasswordUtil.mockResolvedValue(false);
const result = await verifyUserPassword(userId, password);
expect(result).toBe(false);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).toHaveBeenCalledWith(password, "hashed-password");
});
test("should throw ResourceNotFoundError if user not found", async () => {
mockPrismaUserFindUnique.mockResolvedValue(null);
await expect(verifyUserPassword(userId, password)).rejects.toThrow(ResourceNotFoundError);
await expect(verifyUserPassword(userId, password)).rejects.toThrow(`user with ID ${userId} not found`);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
});
test("should throw InvalidInputError if identityProvider is not email", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
password: "hashed-password",
identityProvider: "google", // Not 'email'
} as any);
await expect(verifyUserPassword(userId, password)).rejects.toThrow(InvalidInputError);
await expect(verifyUserPassword(userId, password)).rejects.toThrow("Password is not set for this user");
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
});
test("should throw InvalidInputError if password is not set for email provider", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
password: null, // Password not set
identityProvider: "email",
} as any);
await expect(verifyUserPassword(userId, password)).rejects.toThrow(InvalidInputError);
await expect(verifyUserPassword(userId, password)).rejects.toThrow("Password is not set for this user");
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
});
});
describe("getIsEmailUnique", () => {
const email = "test@example.com";
test("should return false if user exists", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
id: "some-user-id",
} as any);
const result = await getIsEmailUnique(email);
expect(result).toBe(false);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { email },
select: { id: true },
});
});
test("should return true if user does not exist", async () => {
mockPrismaUserFindUnique.mockResolvedValue(null);
const result = await getIsEmailUnique(email);
expect(result).toBe(true);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { email },
select: { id: true },
});
});
});
});
@@ -1,6 +0,0 @@
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import Loading from "@/modules/organization/settings/api-keys/loading";
export default function LoadingPage() {
return <Loading isFormbricksCloud={IS_FORMBRICKS_CLOUD} />;
}
@@ -1,90 +0,0 @@
"use client";
import { usePathname } from "next/navigation";
import { useTranslation } from "react-i18next";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
import { getAccessFlags } from "@/lib/membership/utils";
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
interface OrganizationSettingsNavbarProps {
isFormbricksCloud: boolean;
membershipRole?: TOrganizationRole;
activeId: string;
loading?: boolean;
}
export const OrganizationSettingsNavbar = ({
isFormbricksCloud,
membershipRole,
activeId,
loading,
}: OrganizationSettingsNavbarProps) => {
const pathname = usePathname();
const { isMember, isOwner, isManager } = getAccessFlags(membershipRole);
const isOwnerOrManager = isOwner || isManager;
const isMembershipPending = membershipRole === undefined || loading;
const { t } = useTranslation();
const { workspace } = useWorkspace();
const workspaceBasePath = `/workspaces/${workspace?.id}`;
const navigation = [
{
id: "general",
label: t("common.general"),
href: `${workspaceBasePath}/settings/general`,
current: pathname?.includes("/general"),
hidden: false,
},
{
id: "teams",
label: t("common.members_and_teams"),
href: `${workspaceBasePath}/settings/teams`,
current: pathname?.includes("/teams"),
},
{
id: "feedback-record-directories",
label: t("workspace.settings.feedback_record_directories.nav_label"),
href: `${workspaceBasePath}/settings/feedback-record-directories`,
current: pathname?.includes("/feedback-record-directories"),
hidden: isMember,
},
{
id: "api-keys",
label: t("common.api_keys"),
href: `${workspaceBasePath}/settings/api-keys`,
current: pathname?.includes("/api-keys"),
disabled: isMembershipPending || !isOwnerOrManager,
disabledMessage: isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action"),
},
{
id: "domain",
label: t("common.domain"),
href: `${workspaceBasePath}/settings/domain`,
current: pathname?.includes("/domain"),
hidden: isFormbricksCloud,
},
{
id: "billing",
label: t("common.billing"),
href: `${workspaceBasePath}/settings/billing`,
hidden: !isFormbricksCloud,
current: pathname?.includes("/billing"),
},
{
id: "enterprise",
label: t("common.enterprise_license"),
href: `${workspaceBasePath}/settings/enterprise`,
hidden: isFormbricksCloud,
disabled: isMembershipPending || isMember,
disabledMessage: isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action"),
current: pathname?.includes("/enterprise"),
},
];
return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;
};
@@ -1 +0,0 @@
export { FeedbackRecordDirectoriesPage as default } from "@/modules/ee/feedback-record-directory/page";
@@ -1,36 +0,0 @@
import { getServerSession } from "next-auth";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganization } from "@/lib/organization/service";
import { getWorkspace } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
const Layout = async (props: { params: Promise<{ workspaceId: string }>; children: React.ReactNode }) => {
const params = await props.params;
const { children } = props;
const t = await getTranslate();
const [workspace, session] = await Promise.all([
getWorkspace(params.workspaceId),
getServerSession(authOptions),
]);
if (!workspace) {
throw new ResourceNotFoundError(t("common.workspace"), null);
}
const organization = await getOrganization(workspace.organizationId);
if (!organization) {
throw new ResourceNotFoundError(t("common.organization"), null);
}
if (!session) {
throw new AuthenticationError(t("common.not_authenticated"));
}
return <>{children}</>;
};
export default Layout;
@@ -19,13 +19,13 @@ export const AccountSettingsNavbar = ({ activeId, loading }: AccountSettingsNavb
{
id: "profile",
label: t("common.profile"),
href: `${workspaceBasePath}/settings/profile`,
href: `${workspaceBasePath}/settings/account/profile`,
current: pathname?.includes("/profile"),
},
{
id: "notifications",
label: t("common.notifications"),
href: `${workspaceBasePath}/settings/notifications`,
href: `${workspaceBasePath}/settings/account/notifications`,
current: pathname?.includes("/notifications"),
},
];
@@ -0,0 +1,5 @@
const AccountSettingsLayout = (props: { children: React.ReactNode }) => {
return <>{props.children}</>;
};
export default AccountSettingsLayout;
@@ -99,7 +99,9 @@ export const EditAlerts = ({
)}
<p className="pb-3 pl-4 text-xs text-slate-400">
{t("workspace.settings.notifications.want_to_loop_in_organization_mates")}{" "}
<Link className="font-semibold" href={`/workspaces/${currentWorkspace?.id}/settings/general`}>
<Link
className="font-semibold"
href={`/workspaces/${currentWorkspace?.id}/settings/organization/general`}>
{t("common.invite_them")}
</Link>
</p>
@@ -14,7 +14,7 @@ export const IntegrationsTip = () => {
<p className="text-sm">
{t("workspace.settings.notifications.need_slack_or_discord_notifications")}?
<a
href={`/workspaces/${workspace?.id}/integrations`}
href={`/workspaces/${workspace?.id}/settings/workspace/integrations`}
className="ml-1 cursor-pointer text-sm underline">
{t("workspace.settings.notifications.use_the_integration")}
</a>
@@ -2,7 +2,6 @@
import { useTranslation } from "react-i18next";
import { LoadingCard } from "@/app/(app)/components/LoadingCard";
import { AccountSettingsNavbar } from "@/app/(app)/workspaces/[workspaceId]/settings/(account)/components/AccountSettingsNavbar";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
@@ -18,9 +17,7 @@ const Loading = () => {
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.account_settings")}>
<AccountSettingsNavbar activeId="notifications" loading />
</PageHeader>
<PageHeader pageTitle={t("common.notifications")} />
{cards.map((card, index) => (
<LoadingCard key={index} {...card} />
))}
@@ -2,10 +2,9 @@ import { getServerSession } from "next-auth";
import { prisma } from "@formbricks/database";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TUserNotificationSettings } from "@formbricks/types/user";
import { AccountSettingsNavbar } from "@/app/(app)/workspaces/[workspaceId]/settings/(account)/components/AccountSettingsNavbar";
import { EditAlerts } from "@/app/(app)/workspaces/[workspaceId]/settings/(account)/notifications/components/EditAlerts";
import { IntegrationsTip } from "@/app/(app)/workspaces/[workspaceId]/settings/(account)/notifications/components/IntegrationsTip";
import type { Membership } from "@/app/(app)/workspaces/[workspaceId]/settings/(account)/notifications/types";
import { EditAlerts } from "@/app/(app)/workspaces/[workspaceId]/settings/account/notifications/components/EditAlerts";
import { IntegrationsTip } from "@/app/(app)/workspaces/[workspaceId]/settings/account/notifications/components/IntegrationsTip";
import type { Membership } from "@/app/(app)/workspaces/[workspaceId]/settings/account/notifications/types";
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
import { getUser } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
@@ -128,10 +127,7 @@ const getMemberships = async (userId: string): Promise<Membership[]> => {
return memberships;
};
const Page = async (props: {
params: Promise<{ workspaceId: string }>;
searchParams: Promise<Record<string, string>>;
}) => {
const Page = async (props: { searchParams: Promise<Record<string, string>> }) => {
const searchParams = await props.searchParams;
const t = await getTranslate();
const session = await getServerSession(authOptions);
@@ -155,9 +151,7 @@ const Page = async (props: {
}
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.account_settings")}>
<AccountSettingsNavbar activeId="notifications" />
</PageHeader>
<PageHeader pageTitle={t("common.notifications")} />
<SettingsCard
title={t("workspace.settings.notifications.email_alerts_surveys")}
description={t("workspace.settings.notifications.set_up_an_alert_to_get_an_email_on_new_responses")}>
@@ -6,11 +6,9 @@ import {
TUserUpdateInput,
ZUserPersonalInfoUpdateInput,
} from "@formbricks/types/user";
import {
getIsEmailUnique,
verifyUserPassword,
} from "@/app/(app)/workspaces/[workspaceId]/settings/(account)/profile/lib/user";
import { getIsEmailUnique } from "@/app/(app)/workspaces/[workspaceId]/settings/account/profile/lib/user";
import { EMAIL_VERIFICATION_DISABLED, PASSWORD_RESET_DISABLED } from "@/lib/constants";
import { verifyUserPassword } from "@/lib/user/password";
import { getUser, updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
@@ -1,30 +1,67 @@
"use client";
import type { Session } from "next-auth";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import { DeleteAccountModal } from "@/modules/account/components/DeleteAccountModal";
import {
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";
interface DeleteAccountProps {
session: Session | null;
IS_FORMBRICKS_CLOUD: boolean;
user: TUser;
organizationsWithSingleOwner: TOrganization[];
accountDeletionError?: string | string[];
isMultiOrgEnabled: boolean;
requiresPasswordConfirmation: boolean;
isSsoIdentityConfirmationDisabled: boolean;
}
export const DeleteAccount = ({
session,
IS_FORMBRICKS_CLOUD,
user,
organizationsWithSingleOwner,
accountDeletionError,
isMultiOrgEnabled,
}: {
session: Session | null;
IS_FORMBRICKS_CLOUD: boolean;
user: TUser;
organizationsWithSingleOwner: TOrganization[];
isMultiOrgEnabled: boolean;
}) => {
requiresPasswordConfirmation,
isSsoIdentityConfirmationDisabled,
}: Readonly<DeleteAccountProps>) => {
const [isModalOpen, setModalOpen] = useState(false);
const isDeleteDisabled = !isMultiOrgEnabled && organizationsWithSingleOwner.length > 0;
const { t } = useTranslation();
const accountDeletionErrorCode = Array.isArray(accountDeletionError)
? accountDeletionError[0]
: accountDeletionError;
const hasShownAccountDeletionError = useRef(false);
useEffect(() => {
if (
accountDeletionErrorCode !== ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE ||
hasShownAccountDeletionError.current
) {
return;
}
hasShownAccountDeletionError.current = true;
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);
globalThis.history.replaceState(null, "", url.toString());
}, [accountDeletionErrorCode, t]);
if (!session) {
return null;
}
@@ -32,11 +69,13 @@ export const DeleteAccount = ({
return (
<div>
<DeleteAccountModal
requiresPasswordConfirmation={requiresPasswordConfirmation}
open={isModalOpen}
setOpen={setModalOpen}
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>
@@ -8,7 +8,7 @@ import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user";
import { PasswordConfirmationModal } from "@/app/(app)/workspaces/[workspaceId]/settings/(account)/profile/components/password-confirmation-modal";
import { PasswordConfirmationModal } from "@/app/(app)/workspaces/[workspaceId]/settings/account/profile/components/password-confirmation-modal";
import { appLanguages, sortedAppLanguages } from "@/lib/i18n/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
@@ -0,0 +1,47 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { getIsEmailUnique } from "./user";
vi.mock("@formbricks/database", () => ({
prisma: {
user: {
findUnique: vi.fn(),
},
},
}));
const mockPrismaUserFindUnique = vi.mocked(prisma.user.findUnique);
describe("User Library Tests", () => {
beforeEach(() => {
vi.resetAllMocks();
});
describe("getIsEmailUnique", () => {
const email = "test@example.com";
test("should return false if user exists", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
id: "some-user-id",
} as any);
const result = await getIsEmailUnique(email);
expect(result).toBe(false);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { email },
select: { id: true },
});
});
test("should return true if user does not exist", async () => {
mockPrismaUserFindUnique.mockResolvedValue(null);
const result = await getIsEmailUnique(email);
expect(result).toBe(true);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { email },
select: { id: true },
});
});
});
});
@@ -0,0 +1,15 @@
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
export const getIsEmailUnique = reactCache(async (email: string): Promise<boolean> => {
const user = await prisma.user.findUnique({
where: {
email: email.toLowerCase(),
},
select: {
id: true,
},
});
return !user;
});
@@ -2,7 +2,6 @@
import { useTranslation } from "react-i18next";
import { LoadingCard } from "@/app/(app)/components/LoadingCard";
import { AccountSettingsNavbar } from "@/app/(app)/workspaces/[workspaceId]/settings/(account)/components/AccountSettingsNavbar";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
@@ -28,9 +27,7 @@ const Loading = () => {
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.account_settings")}>
<AccountSettingsNavbar activeId="profile" loading />
</PageHeader>
<PageHeader pageTitle={t("common.profile")} />
{cards.map((card, index) => (
<LoadingCard key={index} {...card} />
))}
@@ -1,13 +1,19 @@
import { AuthenticationError } from "@formbricks/types/errors";
import { AccountSettingsNavbar } from "@/app/(app)/workspaces/[workspaceId]/settings/(account)/components/AccountSettingsNavbar";
import { AccountSecurity } from "@/app/(app)/workspaces/[workspaceId]/settings/(account)/profile/components/AccountSecurity";
import { DeleteAccount } from "@/app/(app)/workspaces/[workspaceId]/settings/(account)/profile/components/DeleteAccount";
import { EditProfileDetailsForm } from "@/app/(app)/workspaces/[workspaceId]/settings/(account)/profile/components/EditProfileDetailsForm";
import { AccountSecurity } from "@/app/(app)/workspaces/[workspaceId]/settings/account/profile/components/AccountSecurity";
import { DeleteAccount } from "@/app/(app)/workspaces/[workspaceId]/settings/account/profile/components/DeleteAccount";
import { EditProfileDetailsForm } from "@/app/(app)/workspaces/[workspaceId]/settings/account/profile/components/EditProfileDetailsForm";
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABLED } from "@/lib/constants";
import {
DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION,
EMAIL_VERIFICATION_DISABLED,
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
IS_FORMBRICKS_CLOUD,
PASSWORD_RESET_DISABLED,
} from "@/lib/constants";
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { requiresPasswordConfirmationForAccountDeletion } from "@/modules/account/lib/account-deletion-auth";
import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
@@ -15,10 +21,14 @@ import { PageHeader } from "@/modules/ui/components/page-header";
import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const Page = async (props: {
params: Promise<{ workspaceId: string }>;
searchParams: Promise<{ accountDeletionError?: string | string[] }>;
}) => {
const isTwoFactorAuthEnabled = await getIsTwoFactorAuthEnabled();
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const params = await props.params;
const searchParams = await props.searchParams;
const t = await getTranslate();
const { session } = await getWorkspaceAuth(params.workspaceId);
@@ -31,12 +41,11 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
}
const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email";
const requiresPasswordConfirmation = requiresPasswordConfirmationForAccountDeletion(user);
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.account_settings")}>
<AccountSettingsNavbar activeId="profile" />
</PageHeader>
<PageHeader pageTitle={t("common.profile")} />
{user && (
<div>
<SettingsCard
@@ -62,13 +71,13 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
? t("common.upgrade_plan")
: t("common.request_trial_license"),
href: IS_FORMBRICKS_CLOUD
? `/workspaces/${params.workspaceId}/settings/billing`
: "https://formbricks.com/upgrade-self-hosting-license",
? `/workspaces/${params.workspaceId}/settings/organization/billing`
: ENTERPRISE_LICENSE_REQUEST_FORM_URL,
},
{
text: t("common.learn_more"),
href: IS_FORMBRICKS_CLOUD
? `/workspaces/${params.workspaceId}/settings/billing`
? `/workspaces/${params.workspaceId}/settings/organization/billing`
: "https://formbricks.com/learn-more-self-hosting-license",
},
]}
@@ -88,6 +97,9 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
user={user}
organizationsWithSingleOwner={organizationsWithSingleOwner}
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" />
@@ -0,0 +1,5 @@
const SettingsLayout = (props: { children: React.ReactNode }) => {
return <>{props.children}</>;
};
export default SettingsLayout;
@@ -0,0 +1,5 @@
import Loading from "@/modules/organization/settings/api-keys/loading";
export default function LoadingPage() {
return <Loading />;
}
@@ -1,5 +1,3 @@
import { OrganizationSettingsNavbar } from "@/app/(app)/workspaces/[workspaceId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
@@ -8,9 +6,7 @@ const Loading = async () => {
const t = await getTranslate();
return (
<PageContentWrapper>
<PageHeader pageTitle={t("workspace.settings.general.organization_settings")}>
<OrganizationSettingsNavbar isFormbricksCloud={IS_FORMBRICKS_CLOUD} activeId="billing" loading />
</PageHeader>
<PageHeader pageTitle={t("common.billing")} />
<div className="my-8 h-64 animate-pulse rounded-xl bg-slate-200"></div>
<div className="my-8 h-96 animate-pulse rounded-md bg-slate-200"></div>
</PageContentWrapper>
@@ -1,8 +1,7 @@
import { notFound } from "next/navigation";
import { AuthenticationError } from "@formbricks/types/errors";
import { OrganizationSettingsNavbar } from "@/app/(app)/workspaces/[workspaceId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { PrettyUrlsTable } from "@/app/(app)/workspaces/[workspaceId]/settings/(organization)/domain/components/pretty-urls-table";
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
import { PrettyUrlsTable } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/domain/components/pretty-urls-table";
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
import { getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
@@ -21,9 +20,7 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
return notFound();
}
const { session, currentUserMembership, organization, isOwner, isManager } = await getWorkspaceAuth(
params.workspaceId
);
const { session, organization, isOwner, isManager } = await getWorkspaceAuth(params.workspaceId);
if (!session) {
throw new AuthenticationError(t("common.not_authenticated"));
@@ -36,13 +33,7 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
return (
<PageContentWrapper>
<PageHeader pageTitle={t("workspace.settings.general.organization_settings")}>
<OrganizationSettingsNavbar
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
membershipRole={currentUserMembership?.role}
activeId="domain"
/>
</PageHeader>
<PageHeader pageTitle={t("common.domain")} />
{!IS_STORAGE_CONFIGURED && (
<div className="max-w-4xl">
@@ -5,7 +5,7 @@ import { RotateCcwIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { Trans, useTranslation } from "react-i18next";
import { formatDateForDisplay, formatDateTimeForDisplay } from "@/lib/utils/datetime";
import { recheckLicenseAction } from "@/modules/ee/license-check/actions";
import type { TLicenseStatus } from "@/modules/ee/license-check/types/enterprise-license";
@@ -151,12 +151,17 @@ export const EnterpriseLicenseStatus = ({
</Alert>
)}
<p className="border-t border-slate-100 pt-4 text-sm text-slate-500">
{t("workspace.settings.enterprise.questions_please_reach_out_to")}{" "}
<a
className="font-medium text-slate-700 underline hover:text-slate-900"
href="mailto:hola@formbricks.com">
hola@formbricks.com
</a>
<Trans
i18nKey="workspace.settings.enterprise.questions_please_reach_out_to_email"
components={{
contactLink: (
<a
className="font-medium text-slate-700 underline hover:text-slate-900"
href="mailto:hola@formbricks.com"
/>
),
}}
/>
</p>
</div>
</SettingsCard>
@@ -1,5 +1,3 @@
import { OrganizationSettingsNavbar } from "@/app/(app)/workspaces/[workspaceId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
@@ -8,9 +6,7 @@ const Loading = async () => {
const t = await getTranslate();
return (
<PageContentWrapper>
<PageHeader pageTitle={t("workspace.settings.general.organization_settings")}>
<OrganizationSettingsNavbar isFormbricksCloud={IS_FORMBRICKS_CLOUD} activeId="enterprise" loading />
</PageHeader>
<PageHeader pageTitle={t("common.enterprise_license")} />
<div className="my-8 h-64 animate-pulse rounded-xl bg-slate-200"></div>
<div className="my-8 h-96 animate-pulse rounded-md bg-slate-200"></div>
</PageContentWrapper>
@@ -1,10 +1,9 @@
import { CheckIcon } from "lucide-react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { OrganizationSettingsNavbar } from "@/app/(app)/workspaces/[workspaceId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { EnterpriseLicenseFeaturesTable } from "@/app/(app)/workspaces/[workspaceId]/settings/(organization)/enterprise/components/EnterpriseLicenseFeaturesTable";
import { EnterpriseLicenseStatus } from "@/app/(app)/workspaces/[workspaceId]/settings/(organization)/enterprise/components/EnterpriseLicenseStatus";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { EnterpriseLicenseFeaturesTable } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/enterprise/components/EnterpriseLicenseFeaturesTable";
import { EnterpriseLicenseStatus } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/enterprise/components/EnterpriseLicenseStatus";
import { ENTERPRISE_LICENSE_REQUEST_FORM_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
import { GRACE_PERIOD_MS, getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import { Button } from "@/modules/ui/components/button";
@@ -19,7 +18,7 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
return notFound();
}
const { isMember, currentUserMembership } = await getWorkspaceAuth(params.workspaceId);
const { isMember } = await getWorkspaceAuth(params.workspaceId);
const isPricingDisabled = isMember;
@@ -85,13 +84,7 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
return (
<PageContentWrapper>
<PageHeader pageTitle={t("workspace.settings.general.organization_settings")}>
<OrganizationSettingsNavbar
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
membershipRole={currentUserMembership?.role}
activeId="enterprise"
/>
</PageHeader>
<PageHeader pageTitle={t("common.enterprise_license")} />
{hasLicense ? (
<>
<EnterpriseLicenseStatus
@@ -170,7 +163,7 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
</p>
<Button asChild>
<Link
href="https://app.formbricks.com/s/clvupq3y205i5yrm3sm9v1xt5"
href={ENTERPRISE_LICENSE_REQUEST_FORM_URL}
target="_blank"
rel="noopener noreferrer nofollow"
referrerPolicy="no-referrer">
@@ -0,0 +1 @@
export { FeedbackDirectoriesPage as default } from "@/modules/ee/feedback-directory/page";
@@ -7,7 +7,7 @@ import { useTranslation } from "react-i18next";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
import { updateOrganizationAISettingsAction } from "@/app/(app)/workspaces/[workspaceId]/settings/(organization)/general/actions";
import { updateOrganizationAISettingsAction } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/general/actions";
import { getDisplayedOrganizationAISettingValue, getOrganizationAIEnablementState } from "@/lib/ai/utils";
import { getAccessFlags } from "@/lib/membership/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
@@ -21,6 +21,7 @@ interface AISettingsToggleProps {
isInstanceAIConfigured: boolean;
hasAIPermission: boolean;
isFormbricksCloud: boolean;
enterpriseLicenseRequestFormUrl: string;
}
export const AISettingsToggle = ({
@@ -29,6 +30,7 @@ export const AISettingsToggle = ({
isInstanceAIConfigured,
hasAIPermission,
isFormbricksCloud,
enterpriseLicenseRequestFormUrl,
}: Readonly<AISettingsToggleProps>) => {
const { workspace } = useWorkspace();
const workspaceBasePath = `/workspaces/${workspace?.id}`;
@@ -90,14 +92,12 @@ export const AISettingsToggle = ({
{
text: isFormbricksCloud ? t("common.upgrade_plan") : t("common.request_trial_license"),
href: isFormbricksCloud
? `${workspaceBasePath}/settings/billing`
: "https://formbricks.com/upgrade-self-hosting-license",
? `${workspaceBasePath}/settings/organization/billing`
: enterpriseLicenseRequestFormUrl,
},
{
text: t("common.learn_more"),
href: isFormbricksCloud
? `${workspaceBasePath}/settings/billing`
: "https://formbricks.com/learn-more-self-hosting-license",
href: "https://formbricks.com/docs/platform/features/ai-features",
},
];
@@ -5,7 +5,7 @@ import { Dispatch, SetStateAction, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TOrganization } from "@formbricks/types/organizations";
import { deleteOrganizationAction } from "@/app/(app)/workspaces/[workspaceId]/settings/(organization)/general/actions";
import { deleteOrganizationAction } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/general/actions";
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
@@ -7,7 +7,7 @@ import { useTranslation } from "react-i18next";
import { z } from "zod";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { TOrganization, ZOrganization } from "@formbricks/types/organizations";
import { updateOrganizationNameAction } from "@/app/(app)/workspaces/[workspaceId]/settings/(organization)/general/actions";
import { updateOrganizationNameAction } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/general/actions";
import { getAccessFlags } from "@/lib/membership/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
@@ -1,6 +1,4 @@
import { LoadingCard } from "@/app/(app)/components/LoadingCard";
import { OrganizationSettingsNavbar } from "@/app/(app)/workspaces/[workspaceId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
@@ -23,9 +21,7 @@ const Loading = async () => {
return (
<PageContentWrapper>
<PageHeader pageTitle={t("workspace.settings.general.organization_settings")}>
<OrganizationSettingsNavbar isFormbricksCloud={IS_FORMBRICKS_CLOUD} activeId="general" loading />
</PageHeader>
<PageHeader pageTitle={t("workspace.settings.general.organization_settings")} />
{cards.map((card, index) => (
<LoadingCard key={index} {...card} />
))}
@@ -1,6 +1,10 @@
import { OrganizationSettingsNavbar } from "@/app/(app)/workspaces/[workspaceId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { isInstanceAIConfigured } from "@/lib/ai/service";
import { FB_LOGO_URL, IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
import {
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
FB_LOGO_URL,
IS_FORMBRICKS_CLOUD,
IS_STORAGE_CONFIGURED,
} from "@/lib/constants";
import { getUser } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import {
@@ -48,13 +52,7 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
return (
<PageContentWrapper>
<PageHeader pageTitle={t("workspace.settings.general.organization_settings")}>
<OrganizationSettingsNavbar
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
membershipRole={currentUserMembership?.role}
activeId="general"
/>
</PageHeader>
<PageHeader pageTitle={t("workspace.settings.general.organization_settings")} />
{!IS_STORAGE_CONFIGURED && (
<div className="max-w-4xl">
<Alert variant="warning">
@@ -77,6 +75,7 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
isInstanceAIConfigured={isInstanceAIConfigured()}
hasAIPermission={hasAIPermission}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
enterpriseLicenseRequestFormUrl={ENTERPRISE_LICENSE_REQUEST_FORM_URL}
/>
</SettingsCard>
<EmailCustomizationSettings
@@ -88,6 +87,7 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
fbLogoUrl={FB_LOGO_URL}
user={user}
isStorageConfigured={IS_STORAGE_CONFIGURED}
enterpriseLicenseRequestFormUrl={ENTERPRISE_LICENSE_REQUEST_FORM_URL}
/>
{isMultiOrgEnabled && (
<SettingsCard
@@ -0,0 +1,5 @@
const OrganizationSettingsLayout = (props: { children: React.ReactNode }) => {
return <>{props.children}</>;
};
export default OrganizationSettingsLayout;
@@ -2,7 +2,7 @@ import { redirect } from "next/navigation";
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const params = await props.params;
return redirect(`/workspaces/${params.workspaceId}/settings/profile`);
return redirect(`/workspaces/${params.workspaceId}/settings/workspace/general`);
};
export default Page;
@@ -0,0 +1 @@
export { UserActionsLoading as default } from "@/modules/workspaces/settings/(setup)/user-actions/loading";
@@ -0,0 +1 @@
export { UserActionsPage as default } from "@/modules/workspaces/settings/(setup)/user-actions/page";
@@ -0,0 +1 @@
export { WorkspaceFeedbackSourcesPage as default } from "@/modules/ee/unify-feedback/sources/page";

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