diff --git a/.github/workflows/release-helm-chart.yml b/.github/workflows/release-helm-chart.yml index 8f8c98d97c6f..7db8ebe9cace 100644 --- a/.github/workflows/release-helm-chart.yml +++ b/.github/workflows/release-helm-chart.yml @@ -83,8 +83,9 @@ jobs: expected_image="ghcr.io/formbricks/formbricks:${VERSION}" image_count="$(grep -c "image: ${expected_image}$" <<< "$rendered" || true)" - if [[ "$image_count" -ne 2 ]]; then - echo "Expected web Deployment and migration Job to render ${expected_image}; found ${image_count} matches" + if [[ "$image_count" -ne 3 ]]; then + printf 'Expected %s in web Deployment and both migration containers; found %s matches\n' \ + "$expected_image" "$image_count" grep "image: ghcr.io/formbricks/formbricks:" <<< "$rendered" || true exit 1 fi diff --git a/apps/web/i18n.lock b/apps/web/i18n.lock index b4e30ba42ace..47db5ded17ef 100644 --- a/apps/web/i18n.lock +++ b/apps/web/i18n.lock @@ -3860,13 +3860,12 @@ checksums: workspace/unify/taxonomy_empty_no_active: a9d0c4a7959fba388f10b7f260736da9 workspace/unify/taxonomy_empty_select_field: dbc61237b3f159cd90dc06529659800b workspace/unify/taxonomy_expand: 1bffd02092bb2dd1ac0455fe93ceb1f2 - workspace/unify/taxonomy_failure_generation_failed: 28330167246e89bc239dcda1d86fc291 + workspace/unify/taxonomy_failure_generation_failed: e7840dd56d9e2538f3fc44cc8cd50c3b workspace/unify/taxonomy_failure_insufficient_data: dbfb31f52373e52c11e5063920f916fd - workspace/unify/taxonomy_failure_internal_error: f98fa903c13cee7a8ee4578aae493b23 - workspace/unify/taxonomy_failure_invalid_output: ba829009c2023add2a7cc5cf55aa1969 - workspace/unify/taxonomy_failure_service_unavailable: 991dd474562ff9f11207b985aff06fb4 + workspace/unify/taxonomy_failure_internal_error: 234db9b67862ec9690a055d2ce8b0403 + workspace/unify/taxonomy_failure_invalid_output: d9816c9abd1ff556cbb892631f56491c + workspace/unify/taxonomy_failure_service_unavailable: 3c8e96a9031b7a65933765476d048efc workspace/unify/taxonomy_feedback_source_fallback: 9ac9f86853d6441eaf25fdd697f963c5 - workspace/unify/taxonomy_fields_unavailable: 419b3ed0c9b689ecb8f6099dbfe3042b workspace/unify/taxonomy_gate_embedding_description: 1c7cdf3a65b6e6fe77e1b55d4027643d workspace/unify/taxonomy_gate_embedding_progress: 03f95e8f70fdec85c29f728f41f7eef2 workspace/unify/taxonomy_gate_embedding_title: 1017dbabb21fee60364ec0191406044b @@ -3875,6 +3874,7 @@ checksums: workspace/unify/taxonomy_gate_records_progress: 65895146df298ea1195cb1d41314a79b workspace/unify/taxonomy_gate_setup_sources: ae72c0874ecd769ecc680b25b251910a workspace/unify/taxonomy_generate: 81e42ee6876e511fd129113c7da645e7 + workspace/unify/taxonomy_generating: d55ce9138ed4a7d033bd9030425bf850 workspace/unify/taxonomy_keyword: c85a01aa86d4b45d821978d2159e0f1b workspace/unify/taxonomy_load_failed: 48dbd5804a8173d4da6684a92475d65c workspace/unify/taxonomy_load_records_failed: 57f6c8c5fa524d7c2d8777315e5036c8 @@ -3905,8 +3905,9 @@ checksums: workspace/unify/taxonomy_sentiment_positive: c0c37bed1a06b7e8cdc72607e56487de workspace/unify/taxonomy_sentiment_very_negative: d8fc168ac8dee517c9d5960bfdd2c1d0 workspace/unify/taxonomy_sentiment_very_positive: 0b5952d9b44604c77b74bb1d3eeb29eb + workspace/unify/taxonomy_service_unavailable: 3329f35b3a5c38c92e56b8b07ec49e0e workspace/unify/taxonomy_source: 6e87903ef260da661b2bf6d858ba68ca - workspace/unify/taxonomy_start_failed: 797224ea1b1af31d98a5b5a1a1c98598 + workspace/unify/taxonomy_start_failed: 3ac21a1a2a1ef837e48414597f04451b workspace/unify/taxonomy_text_records_short: e901114256a076d4315262d252471047 workspace/unify/taxonomy_title: 01288877d7b241a417039e5c998ff21c workspace/unify/taxonomy_view_mode: 36a9b5e3dc153c036d320460d72a03c3 diff --git a/apps/web/lib/turbo-build-outputs.test.ts b/apps/web/lib/turbo-build-outputs.test.ts new file mode 100644 index 000000000000..91478279805c --- /dev/null +++ b/apps/web/lib/turbo-build-outputs.test.ts @@ -0,0 +1,39 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, test } from "vitest"; + +// Guards the Turborepo output exclusions for the web build (see ENG-1805). The generic `build` +// task must exclude `.next/cache/**` and `.next/dev/**` so Turbo never caches the transient +// Next.js cache and dev directories — otherwise they fill local and CI disks (regression of the +// ENG-1662 fix). Because `@formbricks/web` has no `#build` override today it inherits `build`, +// but this test resolves outputs the same way Turbo does so a future package-specific override +// cannot silently drop the exclusions again. + +const here = path.dirname(fileURLToPath(import.meta.url)); +const turboJsonPath = path.resolve(here, "..", "..", "..", "turbo.json"); + +const REQUIRED_EXCLUSIONS = ["!.next/cache/**", "!.next/dev/**"]; + +describe("turbo.json web build excludes transient Next.js dirs", () => { + const turboJson = JSON.parse(fs.readFileSync(turboJsonPath, "utf-8")) as { + tasks: Record; + }; + + // Turbo uses the package-specific task when defined, otherwise the generic one. + const resolvedOutputs = turboJson.tasks["@formbricks/web#build"]?.outputs ?? turboJson.tasks.build.outputs ?? []; + + test("resolved @formbricks/web#build outputs exclude .next/cache and .next/dev", () => { + const missing = REQUIRED_EXCLUSIONS.filter((exclusion) => !resolvedOutputs.includes(exclusion)); + expect( + missing, + `@formbricks/web#build resolved outputs are missing exclusion(s): ${missing.join(", ")}. ` + + "Add them to the build task's `outputs` array so Turbo does not cache transient Next.js dirs (ENG-1805)." + ).toEqual([]); + }); + + test("still caches the deployable build artifacts", () => { + expect(resolvedOutputs).toContain(".next/**"); + expect(resolvedOutputs).toContain("dist/**"); + }); +}); diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json index 65db74c3aeb3..ec634c0bfd37 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -4020,13 +4020,12 @@ "taxonomy_empty_no_active": "No active taxonomy has been generated for this field yet.", "taxonomy_empty_select_field": "Select a feedback field to view its taxonomy.", "taxonomy_expand": "Ausklappen", - "taxonomy_failure_generation_failed": "Taxonomy generation failed.", + "taxonomy_failure_generation_failed": "Die Taxonomie-Generierung ist fehlgeschlagen. Bitte versuche es erneut oder wende dich an deinen Admin oder Support, falls das Problem weiterhin besteht.", "taxonomy_failure_insufficient_data": "Not enough embedded feedback records are available for this field.", - "taxonomy_failure_internal_error": "Taxonomy generation failed because of an internal error.", - "taxonomy_failure_invalid_output": "The generated taxonomy could not be validated.", - "taxonomy_failure_service_unavailable": "Taxonomy generation is temporarily unavailable.", + "taxonomy_failure_internal_error": "Die Taxonomie-Generierung ist aufgrund eines internen Fehlers fehlgeschlagen. Bitte wende dich an deinen Admin oder Support.", + "taxonomy_failure_invalid_output": "Die generierte Taxonomie konnte nicht validiert werden. Bitte versuche es erneut oder wende dich an deinen Admin oder Support, falls das Problem weiterhin besteht.", + "taxonomy_failure_service_unavailable": "Die Taxonomie-Generierung ist vorübergehend nicht verfügbar. Bitte versuche es erneut oder wende dich an deinen Admin oder Support, falls das Problem weiterhin besteht.", "taxonomy_feedback_source_fallback": "feedback", - "taxonomy_fields_unavailable": "Taxonomy fields are unavailable", "taxonomy_gate_embedding_description": "Wir machen dein offenes Text-Feedback für KI lesbar. Das passiert automatisch und dauert normalerweise ein paar Minuten.", "taxonomy_gate_embedding_progress": "{current} / {total} records embedded", "taxonomy_gate_embedding_title": "Preparing your feedback", @@ -4035,6 +4034,7 @@ "taxonomy_gate_records_progress": "{current} / {min} open-text feedback records", "taxonomy_gate_setup_sources": "Setup more feedback sources", "taxonomy_generate": "Generate taxonomy", + "taxonomy_generating": "Wird generiert...", "taxonomy_keyword": "Keyword", "taxonomy_load_failed": "Failed to load taxonomy", "taxonomy_load_records_failed": "Failed to load feedback records", @@ -4065,8 +4065,9 @@ "taxonomy_sentiment_positive": "Positiv", "taxonomy_sentiment_very_negative": "Sehr negativ", "taxonomy_sentiment_very_positive": "Sehr positiv", + "taxonomy_service_unavailable": "Die Taxonomie ist vorübergehend nicht verfügbar. Bitte versuche es erneut oder wende dich an deinen Admin oder Support, falls das Problem weiterhin besteht.", "taxonomy_source": "Source", - "taxonomy_start_failed": "Failed to start taxonomy generation", + "taxonomy_start_failed": "Die Taxonomie-Generierung konnte nicht gestartet werden. Bitte versuche es erneut oder wende dich an deinen Admin oder den Support, falls das Problem weiterhin besteht.", "taxonomy_text_records_short": "{count} text records", "taxonomy_title": "Taxonomy", "taxonomy_view_mode": "Ansehen", diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index 031b902579bc..fcdfd5521130 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -4020,13 +4020,12 @@ "taxonomy_empty_no_active": "No active taxonomy has been generated for this field yet.", "taxonomy_empty_select_field": "Select a feedback field to view its taxonomy.", "taxonomy_expand": "Expand", - "taxonomy_failure_generation_failed": "Taxonomy generation failed.", + "taxonomy_failure_generation_failed": "Taxonomy generation failed. Please try again, or contact your admin or support if the problem persists.", "taxonomy_failure_insufficient_data": "Not enough embedded feedback records are available for this field.", - "taxonomy_failure_internal_error": "Taxonomy generation failed because of an internal error.", - "taxonomy_failure_invalid_output": "The generated taxonomy could not be validated.", - "taxonomy_failure_service_unavailable": "Taxonomy generation is temporarily unavailable.", + "taxonomy_failure_internal_error": "Taxonomy generation failed due to an internal error. Please contact your admin or support.", + "taxonomy_failure_invalid_output": "The generated taxonomy couldn't be validated. Please try again, or contact your admin or support if it persists.", + "taxonomy_failure_service_unavailable": "Taxonomy generation is temporarily unavailable. Please try again, or contact your admin or support if it persists.", "taxonomy_feedback_source_fallback": "feedback", - "taxonomy_fields_unavailable": "Taxonomy fields are unavailable", "taxonomy_gate_embedding_description": "We're making your open text feedback readable for AI. This happens automatically and usually takes a few minutes.", "taxonomy_gate_embedding_progress": "{current} / {total} records embedded", "taxonomy_gate_embedding_title": "Preparing your feedback", @@ -4035,6 +4034,7 @@ "taxonomy_gate_records_progress": "{current} / {min} open-text feedback records", "taxonomy_gate_setup_sources": "Setup more feedback sources", "taxonomy_generate": "Generate taxonomy", + "taxonomy_generating": "Generating...", "taxonomy_keyword": "Keyword", "taxonomy_load_failed": "Failed to load taxonomy", "taxonomy_load_records_failed": "Failed to load feedback records", @@ -4065,8 +4065,9 @@ "taxonomy_sentiment_positive": "Positive", "taxonomy_sentiment_very_negative": "Very negative", "taxonomy_sentiment_very_positive": "Very positive", + "taxonomy_service_unavailable": "Taxonomy is temporarily unavailable. Please try again, or contact your admin or support if the problem persists.", "taxonomy_source": "Source", - "taxonomy_start_failed": "Failed to start taxonomy generation", + "taxonomy_start_failed": "Couldn't start taxonomy generation. Please try again, or contact your admin or support if the problem persists.", "taxonomy_text_records_short": "{count} text records", "taxonomy_title": "Taxonomy", "taxonomy_view_mode": "View", diff --git a/apps/web/locales/es-ES.json b/apps/web/locales/es-ES.json index 540ea1948bb4..cf54d31d6c87 100644 --- a/apps/web/locales/es-ES.json +++ b/apps/web/locales/es-ES.json @@ -4020,13 +4020,12 @@ "taxonomy_empty_no_active": "No active taxonomy has been generated for this field yet.", "taxonomy_empty_select_field": "Select a feedback field to view its taxonomy.", "taxonomy_expand": "Expandir", - "taxonomy_failure_generation_failed": "Taxonomy generation failed.", + "taxonomy_failure_generation_failed": "La generación de taxonomía ha fallado. Por favor, inténtalo de nuevo o contacta con tu administrador o soporte si el problema persiste.", "taxonomy_failure_insufficient_data": "Not enough embedded feedback records are available for this field.", - "taxonomy_failure_internal_error": "Taxonomy generation failed because of an internal error.", - "taxonomy_failure_invalid_output": "The generated taxonomy could not be validated.", - "taxonomy_failure_service_unavailable": "Taxonomy generation is temporarily unavailable.", + "taxonomy_failure_internal_error": "La generación de taxonomía ha fallado debido a un error interno. Por favor, contacta con tu administrador o soporte.", + "taxonomy_failure_invalid_output": "No se ha podido validar la taxonomía generada. Por favor, inténtalo de nuevo o contacta con tu administrador o soporte si persiste.", + "taxonomy_failure_service_unavailable": "La generación de taxonomía no está disponible temporalmente. Por favor, inténtalo de nuevo o contacta con tu administrador o soporte si persiste.", "taxonomy_feedback_source_fallback": "feedback", - "taxonomy_fields_unavailable": "Taxonomy fields are unavailable", "taxonomy_gate_embedding_description": "Estamos haciendo que tus comentarios de texto abierto sean legibles para la IA. Esto ocurre automáticamente y suele tardar unos minutos.", "taxonomy_gate_embedding_progress": "{current} / {total} records embedded", "taxonomy_gate_embedding_title": "Preparing your feedback", @@ -4035,6 +4034,7 @@ "taxonomy_gate_records_progress": "{current} / {min} open-text feedback records", "taxonomy_gate_setup_sources": "Setup more feedback sources", "taxonomy_generate": "Generate taxonomy", + "taxonomy_generating": "Generando...", "taxonomy_keyword": "Keyword", "taxonomy_load_failed": "Failed to load taxonomy", "taxonomy_load_records_failed": "Failed to load feedback records", @@ -4065,8 +4065,9 @@ "taxonomy_sentiment_positive": "Positivo", "taxonomy_sentiment_very_negative": "Muy negativo", "taxonomy_sentiment_very_positive": "Muy positivo", + "taxonomy_service_unavailable": "La taxonomía no está disponible temporalmente. Por favor, inténtalo de nuevo o contacta con tu administrador o soporte si el problema persiste.", "taxonomy_source": "Source", - "taxonomy_start_failed": "Failed to start taxonomy generation", + "taxonomy_start_failed": "No se pudo iniciar la generación de taxonomía. Por favor, inténtalo de nuevo o contacta con tu administrador o con soporte si el problema persiste.", "taxonomy_text_records_short": "{count} text records", "taxonomy_title": "Taxonomy", "taxonomy_view_mode": "Ver", diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json index 58e07d7f60f7..11a711bc11ed 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -4020,13 +4020,12 @@ "taxonomy_empty_no_active": "No active taxonomy has been generated for this field yet.", "taxonomy_empty_select_field": "Select a feedback field to view its taxonomy.", "taxonomy_expand": "Développer", - "taxonomy_failure_generation_failed": "Taxonomy generation failed.", + "taxonomy_failure_generation_failed": "La génération de la taxonomie a échoué. Réessaie ou contacte ton administrateur ou le support si le problème persiste.", "taxonomy_failure_insufficient_data": "Not enough embedded feedback records are available for this field.", - "taxonomy_failure_internal_error": "Taxonomy generation failed because of an internal error.", - "taxonomy_failure_invalid_output": "The generated taxonomy could not be validated.", - "taxonomy_failure_service_unavailable": "Taxonomy generation is temporarily unavailable.", + "taxonomy_failure_internal_error": "La génération de la taxonomie a échoué en raison d'une erreur interne. Contacte ton administrateur ou le support.", + "taxonomy_failure_invalid_output": "La taxonomie générée n'a pas pu être validée. Réessaie ou contacte ton administrateur ou le support si le problème persiste.", + "taxonomy_failure_service_unavailable": "La génération de la taxonomie est temporairement indisponible. Réessaie ou contacte ton administrateur ou le support si le problème persiste.", "taxonomy_feedback_source_fallback": "feedback", - "taxonomy_fields_unavailable": "Taxonomy fields are unavailable", "taxonomy_gate_embedding_description": "Nous rendons tes retours en texte libre lisibles pour l'IA. Cela se fait automatiquement et prend généralement quelques minutes.", "taxonomy_gate_embedding_progress": "{current} / {total} records embedded", "taxonomy_gate_embedding_title": "Preparing your feedback", @@ -4035,6 +4034,7 @@ "taxonomy_gate_records_progress": "{current} / {min} open-text feedback records", "taxonomy_gate_setup_sources": "Setup more feedback sources", "taxonomy_generate": "Generate taxonomy", + "taxonomy_generating": "Génération en cours...", "taxonomy_keyword": "Keyword", "taxonomy_load_failed": "Failed to load taxonomy", "taxonomy_load_records_failed": "Failed to load feedback records", @@ -4065,8 +4065,9 @@ "taxonomy_sentiment_positive": "Positif", "taxonomy_sentiment_very_negative": "Très négatif", "taxonomy_sentiment_very_positive": "Très positif", + "taxonomy_service_unavailable": "La taxonomie est temporairement indisponible. Réessaie ou contacte ton administrateur ou le support si le problème persiste.", "taxonomy_source": "Source", - "taxonomy_start_failed": "Failed to start taxonomy generation", + "taxonomy_start_failed": "Impossible de lancer la génération de taxonomie. Réessaie, ou contacte ton admin ou le support si le problème persiste.", "taxonomy_text_records_short": "{count} text records", "taxonomy_title": "Taxonomy", "taxonomy_view_mode": "Afficher", diff --git a/apps/web/locales/hu-HU.json b/apps/web/locales/hu-HU.json index bb850fba9d82..b3f727b71c8c 100644 --- a/apps/web/locales/hu-HU.json +++ b/apps/web/locales/hu-HU.json @@ -4020,13 +4020,12 @@ "taxonomy_empty_no_active": "No active taxonomy has been generated for this field yet.", "taxonomy_empty_select_field": "Select a feedback field to view its taxonomy.", "taxonomy_expand": "Kibontás", - "taxonomy_failure_generation_failed": "Taxonomy generation failed.", + "taxonomy_failure_generation_failed": "A taxonómia generálása meghiúsult. Kérem, próbálja újra, vagy amennyiben a probléma továbbra is fennáll, forduljon a rendszergazdájához vagy az ügyfélszolgálathoz.", "taxonomy_failure_insufficient_data": "Not enough embedded feedback records are available for this field.", - "taxonomy_failure_internal_error": "Taxonomy generation failed because of an internal error.", - "taxonomy_failure_invalid_output": "The generated taxonomy could not be validated.", - "taxonomy_failure_service_unavailable": "Taxonomy generation is temporarily unavailable.", + "taxonomy_failure_internal_error": "A taxonómia generálása belső hiba miatt meghiúsult. Kérem, forduljon a rendszergazdájához vagy az ügyfélszolgálathoz.", + "taxonomy_failure_invalid_output": "A generált taxonómia validálása sikertelen volt. Kérem, próbálja újra, vagy amennyiben a probléma továbbra is fennáll, forduljon a rendszergazdájához vagy az ügyfélszolgálathoz.", + "taxonomy_failure_service_unavailable": "A taxonómia generálása átmenetileg nem elérhető. Kérem, próbálja újra, vagy amennyiben a probléma továbbra is fennáll, forduljon a rendszergazdájához vagy az ügyfélszolgálathoz.", "taxonomy_feedback_source_fallback": "feedback", - "taxonomy_fields_unavailable": "Taxonomy fields are unavailable", "taxonomy_gate_embedding_description": "A nyílt szöveges visszajelzéseit mesterséges intelligencia által olvashatóvá tesszük. Ez automatikusan történik, és általában néhány percet vesz igénybe.", "taxonomy_gate_embedding_progress": "{current} / {total} records embedded", "taxonomy_gate_embedding_title": "Preparing your feedback", @@ -4035,6 +4034,7 @@ "taxonomy_gate_records_progress": "{current} / {min} open-text feedback records", "taxonomy_gate_setup_sources": "Setup more feedback sources", "taxonomy_generate": "Generate taxonomy", + "taxonomy_generating": "Generálás...", "taxonomy_keyword": "Keyword", "taxonomy_load_failed": "Failed to load taxonomy", "taxonomy_load_records_failed": "Failed to load feedback records", @@ -4065,8 +4065,9 @@ "taxonomy_sentiment_positive": "Pozitív", "taxonomy_sentiment_very_negative": "Nagyon negatív", "taxonomy_sentiment_very_positive": "Nagyon pozitív", + "taxonomy_service_unavailable": "A taxonómia átmenetileg nem elérhető. Kérem, próbálja újra, vagy amennyiben a probléma továbbra is fennáll, forduljon a rendszergazdájához vagy az ügyfélszolgálathoz.", "taxonomy_source": "Source", - "taxonomy_start_failed": "Failed to start taxonomy generation", + "taxonomy_start_failed": "Nem sikerült elindítani a taxonómia generálását. Kérjük, próbálja meg újra, vagy vegye fel a kapcsolatot rendszergazdájával vagy ügyfélszolgálatunkkal, amennyiben a probléma továbbra is fennáll.", "taxonomy_text_records_short": "{count} text records", "taxonomy_title": "Taxonomy", "taxonomy_view_mode": "Megtekintés", diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json index 365ec5b1d642..02db1be83a27 100644 --- a/apps/web/locales/ja-JP.json +++ b/apps/web/locales/ja-JP.json @@ -4020,13 +4020,12 @@ "taxonomy_empty_no_active": "No active taxonomy has been generated for this field yet.", "taxonomy_empty_select_field": "Select a feedback field to view its taxonomy.", "taxonomy_expand": "展開", - "taxonomy_failure_generation_failed": "Taxonomy generation failed.", + "taxonomy_failure_generation_failed": "タクソノミーの生成に失敗しました。もう一度お試しいただくか、問題が解決しない場合は管理者またはサポートにお問い合わせください。", "taxonomy_failure_insufficient_data": "Not enough embedded feedback records are available for this field.", - "taxonomy_failure_internal_error": "Taxonomy generation failed because of an internal error.", - "taxonomy_failure_invalid_output": "The generated taxonomy could not be validated.", - "taxonomy_failure_service_unavailable": "Taxonomy generation is temporarily unavailable.", + "taxonomy_failure_internal_error": "内部エラーによりタクソノミーの生成に失敗しました。管理者またはサポートにお問い合わせください。", + "taxonomy_failure_invalid_output": "生成されたタクソノミーを検証できませんでした。もう一度お試しいただくか、問題が解決しない場合は管理者またはサポートにお問い合わせください。", + "taxonomy_failure_service_unavailable": "タクソノミー生成は一時的に利用できません。もう一度お試しいただくか、問題が解決しない場合は管理者またはサポートにお問い合わせください。", "taxonomy_feedback_source_fallback": "feedback", - "taxonomy_fields_unavailable": "Taxonomy fields are unavailable", "taxonomy_gate_embedding_description": "オープンテキストのフィードバックをAIが読み取れるように処理しています。これは自動的に行われ、通常は数分かかります。", "taxonomy_gate_embedding_progress": "{current} / {total} records embedded", "taxonomy_gate_embedding_title": "Preparing your feedback", @@ -4035,6 +4034,7 @@ "taxonomy_gate_records_progress": "{current} / {min} open-text feedback records", "taxonomy_gate_setup_sources": "Setup more feedback sources", "taxonomy_generate": "Generate taxonomy", + "taxonomy_generating": "生成中...", "taxonomy_keyword": "Keyword", "taxonomy_load_failed": "Failed to load taxonomy", "taxonomy_load_records_failed": "Failed to load feedback records", @@ -4065,8 +4065,9 @@ "taxonomy_sentiment_positive": "ポジティブ", "taxonomy_sentiment_very_negative": "非常にネガティブ", "taxonomy_sentiment_very_positive": "非常にポジティブ", + "taxonomy_service_unavailable": "タクソノミーは一時的に利用できません。もう一度お試しいただくか、問題が解決しない場合は管理者またはサポートにお問い合わせください。", "taxonomy_source": "Source", - "taxonomy_start_failed": "Failed to start taxonomy generation", + "taxonomy_start_failed": "タクソノミーの生成を開始できませんでした。もう一度お試しいただくか、問題が解決しない場合は管理者またはサポートにお問い合わせください。", "taxonomy_text_records_short": "{count} text records", "taxonomy_title": "Taxonomy", "taxonomy_view_mode": "表示", diff --git a/apps/web/locales/nl-NL.json b/apps/web/locales/nl-NL.json index ee3ab0d32ea3..7865a9f7c30d 100644 --- a/apps/web/locales/nl-NL.json +++ b/apps/web/locales/nl-NL.json @@ -4020,13 +4020,12 @@ "taxonomy_empty_no_active": "No active taxonomy has been generated for this field yet.", "taxonomy_empty_select_field": "Select a feedback field to view its taxonomy.", "taxonomy_expand": "Uitklappen", - "taxonomy_failure_generation_failed": "Taxonomy generation failed.", + "taxonomy_failure_generation_failed": "Het genereren van de taxonomie is mislukt. Probeer het opnieuw, of neem contact op met je beheerder of support als het probleem aanhoudt.", "taxonomy_failure_insufficient_data": "Not enough embedded feedback records are available for this field.", - "taxonomy_failure_internal_error": "Taxonomy generation failed because of an internal error.", - "taxonomy_failure_invalid_output": "The generated taxonomy could not be validated.", - "taxonomy_failure_service_unavailable": "Taxonomy generation is temporarily unavailable.", + "taxonomy_failure_internal_error": "Het genereren van de taxonomie is mislukt door een interne fout. Neem contact op met je beheerder of support.", + "taxonomy_failure_invalid_output": "De gegenereerde taxonomie kon niet worden gevalideerd. Probeer het opnieuw, of neem contact op met je beheerder of support als dit blijft gebeuren.", + "taxonomy_failure_service_unavailable": "Het genereren van de taxonomie is tijdelijk niet beschikbaar. Probeer het opnieuw, of neem contact op met je beheerder of support als dit blijft gebeuren.", "taxonomy_feedback_source_fallback": "feedback", - "taxonomy_fields_unavailable": "Taxonomy fields are unavailable", "taxonomy_gate_embedding_description": "We maken je open-tekstfeedback leesbaar voor AI. Dit gebeurt automatisch en duurt meestal een paar minuten.", "taxonomy_gate_embedding_progress": "{current} / {total} records embedded", "taxonomy_gate_embedding_title": "Preparing your feedback", @@ -4035,6 +4034,7 @@ "taxonomy_gate_records_progress": "{current} / {min} open-text feedback records", "taxonomy_gate_setup_sources": "Setup more feedback sources", "taxonomy_generate": "Generate taxonomy", + "taxonomy_generating": "Genereren...", "taxonomy_keyword": "Keyword", "taxonomy_load_failed": "Failed to load taxonomy", "taxonomy_load_records_failed": "Failed to load feedback records", @@ -4065,8 +4065,9 @@ "taxonomy_sentiment_positive": "Positief", "taxonomy_sentiment_very_negative": "Zeer negatief", "taxonomy_sentiment_very_positive": "Zeer positief", + "taxonomy_service_unavailable": "Taxonomie is tijdelijk niet beschikbaar. Probeer het opnieuw, of neem contact op met je beheerder of support als het probleem aanhoudt.", "taxonomy_source": "Source", - "taxonomy_start_failed": "Failed to start taxonomy generation", + "taxonomy_start_failed": "Het genereren van de taxonomie kon niet worden gestart. Probeer het opnieuw, of neem contact op met je beheerder of ondersteuning als het probleem aanhoudt.", "taxonomy_text_records_short": "{count} text records", "taxonomy_title": "Taxonomy", "taxonomy_view_mode": "Weergeven", diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json index 3b27844ac4f6..4e67ca638f47 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -4020,13 +4020,12 @@ "taxonomy_empty_no_active": "No active taxonomy has been generated for this field yet.", "taxonomy_empty_select_field": "Select a feedback field to view its taxonomy.", "taxonomy_expand": "Expandir", - "taxonomy_failure_generation_failed": "Taxonomy generation failed.", + "taxonomy_failure_generation_failed": "A geração da taxonomia falhou. Por favor, tente novamente ou entre em contato com seu administrador ou suporte se o problema persistir.", "taxonomy_failure_insufficient_data": "Not enough embedded feedback records are available for this field.", - "taxonomy_failure_internal_error": "Taxonomy generation failed because of an internal error.", - "taxonomy_failure_invalid_output": "The generated taxonomy could not be validated.", - "taxonomy_failure_service_unavailable": "Taxonomy generation is temporarily unavailable.", + "taxonomy_failure_internal_error": "A geração da taxonomia falhou devido a um erro interno. Por favor, entre em contato com seu administrador ou suporte.", + "taxonomy_failure_invalid_output": "A taxonomia gerada não pôde ser validada. Por favor, tente novamente ou entre em contato com seu administrador ou suporte se o problema persistir.", + "taxonomy_failure_service_unavailable": "A geração da taxonomia está temporariamente indisponível. Por favor, tente novamente ou entre em contato com seu administrador ou suporte se o problema persistir.", "taxonomy_feedback_source_fallback": "feedback", - "taxonomy_fields_unavailable": "Taxonomy fields are unavailable", "taxonomy_gate_embedding_description": "Estamos tornando seu feedback de texto aberto legível para IA. Isso acontece automaticamente e geralmente leva alguns minutos.", "taxonomy_gate_embedding_progress": "{current} / {total} records embedded", "taxonomy_gate_embedding_title": "Preparing your feedback", @@ -4035,6 +4034,7 @@ "taxonomy_gate_records_progress": "{current} / {min} open-text feedback records", "taxonomy_gate_setup_sources": "Setup more feedback sources", "taxonomy_generate": "Generate taxonomy", + "taxonomy_generating": "Gerando...", "taxonomy_keyword": "Keyword", "taxonomy_load_failed": "Failed to load taxonomy", "taxonomy_load_records_failed": "Failed to load feedback records", @@ -4065,8 +4065,9 @@ "taxonomy_sentiment_positive": "Positivo", "taxonomy_sentiment_very_negative": "Muito negativo", "taxonomy_sentiment_very_positive": "Muito positivo", + "taxonomy_service_unavailable": "A taxonomia está temporariamente indisponível. Por favor, tente novamente ou entre em contato com seu administrador ou suporte se o problema persistir.", "taxonomy_source": "Source", - "taxonomy_start_failed": "Failed to start taxonomy generation", + "taxonomy_start_failed": "Não foi possível iniciar a geração de taxonomia. Por favor, tente novamente ou entre em contato com seu admin ou suporte se o problema persistir.", "taxonomy_text_records_short": "{count} text records", "taxonomy_title": "Taxonomy", "taxonomy_view_mode": "Visualizar", diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json index 8c0a77e34f8c..67a8b7582632 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -4020,13 +4020,12 @@ "taxonomy_empty_no_active": "No active taxonomy has been generated for this field yet.", "taxonomy_empty_select_field": "Select a feedback field to view its taxonomy.", "taxonomy_expand": "Expandir", - "taxonomy_failure_generation_failed": "Taxonomy generation failed.", + "taxonomy_failure_generation_failed": "A geração da taxonomia falhou. Por favor, tenta novamente ou contacta o teu administrador ou suporte se o problema persistir.", "taxonomy_failure_insufficient_data": "Not enough embedded feedback records are available for this field.", - "taxonomy_failure_internal_error": "Taxonomy generation failed because of an internal error.", - "taxonomy_failure_invalid_output": "The generated taxonomy could not be validated.", - "taxonomy_failure_service_unavailable": "Taxonomy generation is temporarily unavailable.", + "taxonomy_failure_internal_error": "A geração da taxonomia falhou devido a um erro interno. Por favor, contacta o teu administrador ou suporte.", + "taxonomy_failure_invalid_output": "Não foi possível validar a taxonomia gerada. Por favor, tenta novamente ou contacta o teu administrador ou suporte se o problema persistir.", + "taxonomy_failure_service_unavailable": "A geração da taxonomia está temporariamente indisponível. Por favor, tenta novamente ou contacta o teu administrador ou suporte se o problema persistir.", "taxonomy_feedback_source_fallback": "feedback", - "taxonomy_fields_unavailable": "Taxonomy fields are unavailable", "taxonomy_gate_embedding_description": "Estamos a tornar o teu feedback de texto aberto legível para IA. Isto acontece automaticamente e geralmente demora alguns minutos.", "taxonomy_gate_embedding_progress": "{current} / {total} records embedded", "taxonomy_gate_embedding_title": "Preparing your feedback", @@ -4035,6 +4034,7 @@ "taxonomy_gate_records_progress": "{current} / {min} open-text feedback records", "taxonomy_gate_setup_sources": "Setup more feedback sources", "taxonomy_generate": "Generate taxonomy", + "taxonomy_generating": "A gerar...", "taxonomy_keyword": "Keyword", "taxonomy_load_failed": "Failed to load taxonomy", "taxonomy_load_records_failed": "Failed to load feedback records", @@ -4065,8 +4065,9 @@ "taxonomy_sentiment_positive": "Positivo", "taxonomy_sentiment_very_negative": "Muito negativo", "taxonomy_sentiment_very_positive": "Muito positivo", + "taxonomy_service_unavailable": "A taxonomia está temporariamente indisponível. Por favor, tenta novamente ou contacta o teu administrador ou suporte se o problema persistir.", "taxonomy_source": "Source", - "taxonomy_start_failed": "Failed to start taxonomy generation", + "taxonomy_start_failed": "Não foi possível iniciar a geração de taxonomia. Por favor, tenta novamente ou contacta o teu administrador ou suporte se o problema persistir.", "taxonomy_text_records_short": "{count} text records", "taxonomy_title": "Taxonomy", "taxonomy_view_mode": "Ver", diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json index 40ab48aee398..18fcfee66756 100644 --- a/apps/web/locales/ro-RO.json +++ b/apps/web/locales/ro-RO.json @@ -4020,13 +4020,12 @@ "taxonomy_empty_no_active": "No active taxonomy has been generated for this field yet.", "taxonomy_empty_select_field": "Select a feedback field to view its taxonomy.", "taxonomy_expand": "Extinde", - "taxonomy_failure_generation_failed": "Taxonomy generation failed.", + "taxonomy_failure_generation_failed": "Generarea taxonomiei a eșuat. Te rugăm să încerci din nou sau contactează administratorul sau echipa de suport dacă problema persistă.", "taxonomy_failure_insufficient_data": "Not enough embedded feedback records are available for this field.", - "taxonomy_failure_internal_error": "Taxonomy generation failed because of an internal error.", - "taxonomy_failure_invalid_output": "The generated taxonomy could not be validated.", - "taxonomy_failure_service_unavailable": "Taxonomy generation is temporarily unavailable.", + "taxonomy_failure_internal_error": "Generarea taxonomiei a eșuat din cauza unei erori interne. Te rugăm să contactezi administratorul sau echipa de suport.", + "taxonomy_failure_invalid_output": "Taxonomia generată nu a putut fi validată. Te rugăm să încerci din nou sau contactează administratorul sau echipa de suport dacă problema persistă.", + "taxonomy_failure_service_unavailable": "Generarea taxonomiei este temporar indisponibilă. Te rugăm să încerci din nou sau contactează administratorul sau echipa de suport dacă problema persistă.", "taxonomy_feedback_source_fallback": "feedback", - "taxonomy_fields_unavailable": "Taxonomy fields are unavailable", "taxonomy_gate_embedding_description": "Facem feedback-ul tău în text deschis lizibil pentru AI. Acest lucru se întâmplă automat și durează de obicei câteva minute.", "taxonomy_gate_embedding_progress": "{current} / {total} records embedded", "taxonomy_gate_embedding_title": "Preparing your feedback", @@ -4035,6 +4034,7 @@ "taxonomy_gate_records_progress": "{current} / {min} open-text feedback records", "taxonomy_gate_setup_sources": "Setup more feedback sources", "taxonomy_generate": "Generate taxonomy", + "taxonomy_generating": "Se generează...", "taxonomy_keyword": "Keyword", "taxonomy_load_failed": "Failed to load taxonomy", "taxonomy_load_records_failed": "Failed to load feedback records", @@ -4065,8 +4065,9 @@ "taxonomy_sentiment_positive": "Pozitiv", "taxonomy_sentiment_very_negative": "Foarte negativ", "taxonomy_sentiment_very_positive": "Foarte pozitiv", + "taxonomy_service_unavailable": "Taxonomia este temporar indisponibilă. Te rugăm să încerci din nou sau contactează administratorul sau echipa de suport dacă problema persistă.", "taxonomy_source": "Source", - "taxonomy_start_failed": "Failed to start taxonomy generation", + "taxonomy_start_failed": "Nu s-a putut începe generarea taxonomiei. Te rugăm să încerci din nou sau să contactezi administratorul sau suportul dacă problema persistă.", "taxonomy_text_records_short": "{count} text records", "taxonomy_title": "Taxonomy", "taxonomy_view_mode": "Vizualizează", diff --git a/apps/web/locales/ru-RU.json b/apps/web/locales/ru-RU.json index 483aca3b05bd..16f4116eb0d9 100644 --- a/apps/web/locales/ru-RU.json +++ b/apps/web/locales/ru-RU.json @@ -4020,13 +4020,12 @@ "taxonomy_empty_no_active": "No active taxonomy has been generated for this field yet.", "taxonomy_empty_select_field": "Select a feedback field to view its taxonomy.", "taxonomy_expand": "Развернуть", - "taxonomy_failure_generation_failed": "Taxonomy generation failed.", + "taxonomy_failure_generation_failed": "Не удалось создать таксономию. Попробуй снова или обратись к администратору или в поддержку, если проблема сохраняется.", "taxonomy_failure_insufficient_data": "Not enough embedded feedback records are available for this field.", - "taxonomy_failure_internal_error": "Taxonomy generation failed because of an internal error.", - "taxonomy_failure_invalid_output": "The generated taxonomy could not be validated.", - "taxonomy_failure_service_unavailable": "Taxonomy generation is temporarily unavailable.", + "taxonomy_failure_internal_error": "Не удалось создать таксономию из-за внутренней ошибки. Пожалуйста, обратись к администратору или в поддержку.", + "taxonomy_failure_invalid_output": "Не удалось проверить созданную таксономию. Попробуй снова или обратись к администратору или в поддержку, если проблема сохраняется.", + "taxonomy_failure_service_unavailable": "Создание таксономии временно недоступно. Попробуй снова или обратись к администратору или в поддержку, если проблема сохраняется.", "taxonomy_feedback_source_fallback": "feedback", - "taxonomy_fields_unavailable": "Taxonomy fields are unavailable", "taxonomy_gate_embedding_description": "Мы делаем вашу обратную связь в свободной форме понятной для ИИ. Это происходит автоматически и обычно занимает несколько минут.", "taxonomy_gate_embedding_progress": "{current} / {total} records embedded", "taxonomy_gate_embedding_title": "Preparing your feedback", @@ -4035,6 +4034,7 @@ "taxonomy_gate_records_progress": "{current} / {min} open-text feedback records", "taxonomy_gate_setup_sources": "Setup more feedback sources", "taxonomy_generate": "Generate taxonomy", + "taxonomy_generating": "Создание...", "taxonomy_keyword": "Keyword", "taxonomy_load_failed": "Failed to load taxonomy", "taxonomy_load_records_failed": "Failed to load feedback records", @@ -4065,8 +4065,9 @@ "taxonomy_sentiment_positive": "Позитивная", "taxonomy_sentiment_very_negative": "Очень негативная", "taxonomy_sentiment_very_positive": "Очень позитивная", + "taxonomy_service_unavailable": "Таксономия временно недоступна. Попробуй снова или обратись к администратору или в поддержку, если проблема сохраняется.", "taxonomy_source": "Source", - "taxonomy_start_failed": "Failed to start taxonomy generation", + "taxonomy_start_failed": "Не удалось запустить создание таксономии. Попробуйте ещё раз или свяжитесь с администратором или службой поддержки, если проблема сохраняется.", "taxonomy_text_records_short": "{count} text records", "taxonomy_title": "Taxonomy", "taxonomy_view_mode": "Просмотр", diff --git a/apps/web/locales/sv-SE.json b/apps/web/locales/sv-SE.json index 72a241e83ba8..315d8e450466 100644 --- a/apps/web/locales/sv-SE.json +++ b/apps/web/locales/sv-SE.json @@ -4020,13 +4020,12 @@ "taxonomy_empty_no_active": "No active taxonomy has been generated for this field yet.", "taxonomy_empty_select_field": "Select a feedback field to view its taxonomy.", "taxonomy_expand": "Visa", - "taxonomy_failure_generation_failed": "Taxonomy generation failed.", + "taxonomy_failure_generation_failed": "Taxonomigenerering misslyckades. Försök igen, eller kontakta din administratör eller support om problemet kvarstår.", "taxonomy_failure_insufficient_data": "Not enough embedded feedback records are available for this field.", - "taxonomy_failure_internal_error": "Taxonomy generation failed because of an internal error.", - "taxonomy_failure_invalid_output": "The generated taxonomy could not be validated.", - "taxonomy_failure_service_unavailable": "Taxonomy generation is temporarily unavailable.", + "taxonomy_failure_internal_error": "Taxonomigenerering misslyckades på grund av ett internt fel. Kontakta din administratör eller support.", + "taxonomy_failure_invalid_output": "Den genererade taxonomin kunde inte valideras. Försök igen, eller kontakta din administratör eller support om det kvarstår.", + "taxonomy_failure_service_unavailable": "Taxonomigenerering är tillfälligt otillgänglig. Försök igen, eller kontakta din administratör eller support om det kvarstår.", "taxonomy_feedback_source_fallback": "feedback", - "taxonomy_fields_unavailable": "Taxonomy fields are unavailable", "taxonomy_gate_embedding_description": "Vi gör din öppna textfeedback läsbar för AI. Detta sker automatiskt och tar vanligtvis några minuter.", "taxonomy_gate_embedding_progress": "{current} / {total} records embedded", "taxonomy_gate_embedding_title": "Preparing your feedback", @@ -4035,6 +4034,7 @@ "taxonomy_gate_records_progress": "{current} / {min} open-text feedback records", "taxonomy_gate_setup_sources": "Setup more feedback sources", "taxonomy_generate": "Generate taxonomy", + "taxonomy_generating": "Genererar...", "taxonomy_keyword": "Keyword", "taxonomy_load_failed": "Failed to load taxonomy", "taxonomy_load_records_failed": "Failed to load feedback records", @@ -4065,8 +4065,9 @@ "taxonomy_sentiment_positive": "Positivt", "taxonomy_sentiment_very_negative": "Mycket negativt", "taxonomy_sentiment_very_positive": "Mycket positivt", + "taxonomy_service_unavailable": "Taxonomi är tillfälligt otillgänglig. Försök igen, eller kontakta din administratör eller support om problemet kvarstår.", "taxonomy_source": "Source", - "taxonomy_start_failed": "Failed to start taxonomy generation", + "taxonomy_start_failed": "Kunde inte starta taxonomigenerering. Försök igen, eller kontakta din admin eller support om problemet kvarstår.", "taxonomy_text_records_short": "{count} text records", "taxonomy_title": "Taxonomy", "taxonomy_view_mode": "Visa", diff --git a/apps/web/locales/tr-TR.json b/apps/web/locales/tr-TR.json index 5a430534db24..1e865fe02cb1 100644 --- a/apps/web/locales/tr-TR.json +++ b/apps/web/locales/tr-TR.json @@ -4020,13 +4020,12 @@ "taxonomy_empty_no_active": "No active taxonomy has been generated for this field yet.", "taxonomy_empty_select_field": "Select a feedback field to view its taxonomy.", "taxonomy_expand": "Genişlet", - "taxonomy_failure_generation_failed": "Taxonomy generation failed.", + "taxonomy_failure_generation_failed": "Taksonomi oluşturma başarısız oldu. Lütfen tekrar dene veya sorun devam ederse yöneticine ya da destek ekibine ulaş.", "taxonomy_failure_insufficient_data": "Not enough embedded feedback records are available for this field.", - "taxonomy_failure_internal_error": "Taxonomy generation failed because of an internal error.", - "taxonomy_failure_invalid_output": "The generated taxonomy could not be validated.", - "taxonomy_failure_service_unavailable": "Taxonomy generation is temporarily unavailable.", + "taxonomy_failure_internal_error": "Taksonomi oluşturma dahili bir hata nedeniyle başarısız oldu. Lütfen yöneticine veya destek ekibine ulaş.", + "taxonomy_failure_invalid_output": "Oluşturulan taksonomi doğrulanamadı. Lütfen tekrar dene veya sorun devam ederse yöneticine ya da destek ekibine ulaş.", + "taxonomy_failure_service_unavailable": "Taksonomi oluşturma geçici olarak kullanılamıyor. Lütfen tekrar dene veya sorun devam ederse yöneticine ya da destek ekibine ulaş.", "taxonomy_feedback_source_fallback": "feedback", - "taxonomy_fields_unavailable": "Taxonomy fields are unavailable", "taxonomy_gate_embedding_description": "Açık metin geri bildirimlerini yapay zeka için okunabilir hale getiriyoruz. Bu otomatik olarak gerçekleşir ve genellikle birkaç dakika sürer.", "taxonomy_gate_embedding_progress": "{current} / {total} records embedded", "taxonomy_gate_embedding_title": "Preparing your feedback", @@ -4035,6 +4034,7 @@ "taxonomy_gate_records_progress": "{current} / {min} open-text feedback records", "taxonomy_gate_setup_sources": "Setup more feedback sources", "taxonomy_generate": "Generate taxonomy", + "taxonomy_generating": "Oluşturuluyor...", "taxonomy_keyword": "Keyword", "taxonomy_load_failed": "Failed to load taxonomy", "taxonomy_load_records_failed": "Failed to load feedback records", @@ -4065,8 +4065,9 @@ "taxonomy_sentiment_positive": "Olumlu", "taxonomy_sentiment_very_negative": "Çok olumsuz", "taxonomy_sentiment_very_positive": "Çok olumlu", + "taxonomy_service_unavailable": "Taksonomi geçici olarak kullanılamıyor. Lütfen tekrar dene veya sorun devam ederse yöneticine ya da destek ekibine ulaş.", "taxonomy_source": "Source", - "taxonomy_start_failed": "Failed to start taxonomy generation", + "taxonomy_start_failed": "Taksonomi oluşturma başlatılamadı. Lütfen tekrar deneyin veya sorun devam ederse yöneticinizle veya destek ekibiyle iletişime geçin.", "taxonomy_text_records_short": "{count} text records", "taxonomy_title": "Taxonomy", "taxonomy_view_mode": "Görüntüle", diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json index e665b1d8cc9a..0dbfc4ed4bec 100644 --- a/apps/web/locales/zh-Hans-CN.json +++ b/apps/web/locales/zh-Hans-CN.json @@ -4020,13 +4020,12 @@ "taxonomy_empty_no_active": "No active taxonomy has been generated for this field yet.", "taxonomy_empty_select_field": "Select a feedback field to view its taxonomy.", "taxonomy_expand": "展开", - "taxonomy_failure_generation_failed": "Taxonomy generation failed.", + "taxonomy_failure_generation_failed": "分类生成失败。请重试,如果问题仍然存在,请联系您的管理员或支持团队。", "taxonomy_failure_insufficient_data": "Not enough embedded feedback records are available for this field.", - "taxonomy_failure_internal_error": "Taxonomy generation failed because of an internal error.", - "taxonomy_failure_invalid_output": "The generated taxonomy could not be validated.", - "taxonomy_failure_service_unavailable": "Taxonomy generation is temporarily unavailable.", + "taxonomy_failure_internal_error": "分类生成因内部错误而失败。请联系您的管理员或支持团队。", + "taxonomy_failure_invalid_output": "无法验证生成的分类。请重试,如果问题仍然存在,请联系您的管理员或支持团队。", + "taxonomy_failure_service_unavailable": "分类生成服务暂时不可用。请重试,如果问题仍然存在,请联系您的管理员或支持团队。", "taxonomy_feedback_source_fallback": "feedback", - "taxonomy_fields_unavailable": "Taxonomy fields are unavailable", "taxonomy_gate_embedding_description": "我们正在让你的开放文本反馈变得可供 AI 读取。这会自动进行,通常需要几分钟时间。", "taxonomy_gate_embedding_progress": "{current} / {total} records embedded", "taxonomy_gate_embedding_title": "Preparing your feedback", @@ -4035,6 +4034,7 @@ "taxonomy_gate_records_progress": "{current} / {min} open-text feedback records", "taxonomy_gate_setup_sources": "Setup more feedback sources", "taxonomy_generate": "Generate taxonomy", + "taxonomy_generating": "生成中...", "taxonomy_keyword": "Keyword", "taxonomy_load_failed": "Failed to load taxonomy", "taxonomy_load_records_failed": "Failed to load feedback records", @@ -4065,8 +4065,9 @@ "taxonomy_sentiment_positive": "正面", "taxonomy_sentiment_very_negative": "非常负面", "taxonomy_sentiment_very_positive": "非常正面", + "taxonomy_service_unavailable": "分类服务暂时不可用。请重试,如果问题仍然存在,请联系您的管理员或支持团队。", "taxonomy_source": "Source", - "taxonomy_start_failed": "Failed to start taxonomy generation", + "taxonomy_start_failed": "无法启动分类生成。请重试,如果问题持续存在,请联系你的管理员或支持团队。", "taxonomy_text_records_short": "{count} text records", "taxonomy_title": "Taxonomy", "taxonomy_view_mode": "查看", diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json index 6a27005bb83d..4fd1708357b1 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -4020,13 +4020,12 @@ "taxonomy_empty_no_active": "No active taxonomy has been generated for this field yet.", "taxonomy_empty_select_field": "Select a feedback field to view its taxonomy.", "taxonomy_expand": "展開", - "taxonomy_failure_generation_failed": "Taxonomy generation failed.", + "taxonomy_failure_generation_failed": "分類生成失敗。請重試,或如果問題持續發生,請聯繫您的管理員或支援團隊。", "taxonomy_failure_insufficient_data": "Not enough embedded feedback records are available for this field.", - "taxonomy_failure_internal_error": "Taxonomy generation failed because of an internal error.", - "taxonomy_failure_invalid_output": "The generated taxonomy could not be validated.", - "taxonomy_failure_service_unavailable": "Taxonomy generation is temporarily unavailable.", + "taxonomy_failure_internal_error": "分類生成因內部錯誤而失敗。請聯繫您的管理員或支援團隊。", + "taxonomy_failure_invalid_output": "無法驗證所生成的分類。請重試,或如果問題持續發生,請聯繫您的管理員或支援團隊。", + "taxonomy_failure_service_unavailable": "分類生成暫時無法使用。請重試,或如果問題持續發生,請聯繫您的管理員或支援團隊。", "taxonomy_feedback_source_fallback": "feedback", - "taxonomy_fields_unavailable": "Taxonomy fields are unavailable", "taxonomy_gate_embedding_description": "我們正在讓你的開放式文字回饋能夠被 AI 讀取。這個過程會自動進行,通常需要幾分鐘時間。", "taxonomy_gate_embedding_progress": "{current} / {total} records embedded", "taxonomy_gate_embedding_title": "Preparing your feedback", @@ -4035,6 +4034,7 @@ "taxonomy_gate_records_progress": "{current} / {min} open-text feedback records", "taxonomy_gate_setup_sources": "Setup more feedback sources", "taxonomy_generate": "Generate taxonomy", + "taxonomy_generating": "生成中...", "taxonomy_keyword": "Keyword", "taxonomy_load_failed": "Failed to load taxonomy", "taxonomy_load_records_failed": "Failed to load feedback records", @@ -4065,8 +4065,9 @@ "taxonomy_sentiment_positive": "正面", "taxonomy_sentiment_very_negative": "非常負面", "taxonomy_sentiment_very_positive": "非常正面", + "taxonomy_service_unavailable": "分類暫時無法使用。請重試,或如果問題持續發生,請聯繫您的管理員或支援團隊。", "taxonomy_source": "Source", - "taxonomy_start_failed": "Failed to start taxonomy generation", + "taxonomy_start_failed": "無法開始分類產生。請再試一次,如果問題持續發生,請聯絡你的管理員或支援團隊。", "taxonomy_text_records_short": "{count} text records", "taxonomy_title": "Taxonomy", "taxonomy_view_mode": "檢視", diff --git a/apps/web/modules/ee/sso/lib/better-auth-hooks.test.ts b/apps/web/modules/ee/sso/lib/better-auth-hooks.test.ts index b4e8c33fb838..d285edc91885 100644 --- a/apps/web/modules/ee/sso/lib/better-auth-hooks.test.ts +++ b/apps/web/modules/ee/sso/lib/better-auth-hooks.test.ts @@ -149,11 +149,46 @@ describe("ssoDatabaseHooks.user.create.before", () => { expect(gateSsoProvisioning).toHaveBeenCalledWith({ email: "john.doe@example.com", callbackUrl: "/" }); }); - test("keeps the provider-supplied name (no fallback) when present", async () => { + test("normalizes a clean provider-supplied name (unchanged, no fallback) when present", async () => { const result = await runWithSsoRequestContext(() => before({ id: "u1", email: "a@b.com", name: "Ada Lovelace" } as never, callbackCtx as never) ); - expect(result).toEqual({ data: { emailVerified: true, identityProvider: "openid", locale: "en-US" } }); + expect(result).toEqual({ + data: { emailVerified: true, identityProvider: "openid", locale: "en-US", name: "Ada Lovelace" }, + }); + }); + + // ENG-1743: an IdP display name with common punctuation must never fail the sign-in. The provider + // name is normalized to a ZUserName-valid form (allowlisted punctuation preserved, the rest collapsed + // to a single space), so no `ValidationError: Invalid name format` is thrown from the SSO create path. + test.each([ + { name: "J. Smith", expected: "J. Smith" }, // period preserved + { name: "Smith & Co", expected: "Smith & Co" }, // ampersand preserved + { name: "Ada O'Neil", expected: "Ada O'Neil" }, // apostrophe kept, double space collapsed + { name: "A/B Corp", expected: "A B Corp" }, // slash (not allowlisted) collapsed to space + { name: "José 🎉 Núñez", expected: "José Núñez" }, // emoji stripped, accented letters kept + ])("normalizes a punctuated provider name '$name' → '$expected'", async ({ name, expected }) => { + const result = await runWithSsoRequestContext(() => + before({ id: "u1", email: "a@b.com", name } as never, callbackCtx as never) + ); + expect(result).toMatchObject({ data: { name: expected } }); + }); + + test("falls back to the email local-part when the provider name normalizes to empty", async () => { + const result = await runWithSsoRequestContext(() => + before({ id: "u1", email: "jane.doe@example.com", name: "🎉🎉" } as never, callbackCtx as never) + ); + expect(result).toMatchObject({ data: { name: "jane doe" } }); + }); + + // ENG-1743 edge: if BOTH the provider name and the email local-part normalize to empty (a degenerate + // service/machine account), fall back to a constant so the stored name is a valid non-empty + // ZUserName — otherwise "" would pass Better Auth's create but throw on the user's first profile save. + test("falls back to a constant when the provider name and email local-part both normalize to empty", async () => { + const result = await runWithSsoRequestContext(() => + before({ id: "u1", email: "🎉@example.com", name: "🎉🎉" } as never, callbackCtx as never) + ); + expect(result).toMatchObject({ data: { name: "User" } }); }); test("leaves email/password sign-ups untouched (gate not run)", async () => { diff --git a/apps/web/modules/ee/sso/lib/better-auth-hooks.ts b/apps/web/modules/ee/sso/lib/better-auth-hooks.ts index 732bbe60da2b..630d05196a98 100644 --- a/apps/web/modules/ee/sso/lib/better-auth-hooks.ts +++ b/apps/web/modules/ee/sso/lib/better-auth-hooks.ts @@ -4,6 +4,7 @@ import { APIError, createAuthMiddleware, getOAuthState } from "better-auth/api"; import { cookies } from "next/headers"; import { prisma } from "@formbricks/database"; import { SIGNUP_EMAIL_DOMAIN_BLOCKED_ERROR_CODE } from "@formbricks/types/errors"; +import { normalizeUserName } from "@formbricks/types/user"; import { WEBAPP_URL } from "@/lib/constants"; import { identifyPostHogPerson } from "@/lib/posthog"; import { findMatchingLocale } from "@/lib/utils/locale"; @@ -45,12 +46,13 @@ export const getSsoProviderFromContext = ( return match ? match[1] : null; }; -/** Fallback display name derived from an email local-part (parity `provisionNewSsoUser`:372-377). */ +/** + * Fallback display name when the IdP supplies no name: humanize the email local-part (treat `. _ +` as + * word separators) and run it through the shared normalizer. The name allowlist lives only in + * normalizeUserName (tied to ZUserName), so there is no second name regex here that could drift. + */ const deriveNameFromEmail = (email: string): string => - email - .split("@")[0] - .replace(/[^'\p{L}\p{M}\s\d-]+/gu, " ") - .trim(); + normalizeUserName(email.split("@")[0].replace(/[._+]+/g, " ")); /** * Better Auth `databaseHooks` re-expressing Formbricks' SSO sign-up flow (design doc §13), reusing @@ -124,7 +126,13 @@ export const ssoDatabaseHooks: NonNullable = // picture): transformInput drops undefined fields that have no schema default, so this // prevents a prisma.user.create validation error on SSO sign-up. image: undefined, - ...(user.name ? {} : { name: deriveNameFromEmail(user.email) }), + // Normalize the IdP-supplied display name to a form ZUserName accepts (ENG-1743): external + // provider names are untrusted and may carry punctuation ("J. Smith", "Smith & Co") that + // would otherwise persist raw and later break a profile save. Fall back to the email + // local-part, then to a constant, so the stored name is always a valid, non-empty + // ZUserName — even for degenerate input (emoji-only name + symbol-only email local-part), + // which would otherwise re-trigger the ENG-1743 error on the user's first profile save. + name: (user.name && normalizeUserName(user.name)) || deriveNameFromEmail(user.email) || "User", }, }; }, @@ -171,7 +179,7 @@ export const ssoDatabaseHooks: NonNullable = /** * Request hook (`hooks.before`) that re-checks the SSO license on every SSO callback — parity with - * `handleSsoCallback`'s runtime checks (sso-handlers.ts:492-523). Provider registration is gated by + * the legacy NextAuth SSO callback's runtime license checks. Provider registration is gated by * `ENTERPRISE_LICENSE_KEY` (broad); this verifies the specific `sso`/`saml` feature flags on every * callback. It runs for ALL SSO sign-ins (including existing users, who skip `user.create` and so * aren't seen by the databaseHooks gate) and catches a license that changes at runtime. Blocks with diff --git a/apps/web/modules/ee/sso/lib/sso-handlers.ts b/apps/web/modules/ee/sso/lib/sso-handlers.ts deleted file mode 100644 index 4b4a088b621d..000000000000 --- a/apps/web/modules/ee/sso/lib/sso-handlers.ts +++ /dev/null @@ -1,603 +0,0 @@ -import { prisma } from "@formbricks/database"; -import type { IdentityProvider, Organization } from "@formbricks/database/prisma"; -import { logger } from "@formbricks/logger"; -import type { Account } from "@formbricks/types/auth"; -import type { TUser, TUserNotificationSettings } from "@formbricks/types/user"; -import { DEFAULT_TEAM_ID, SKIP_INVITE_FOR_SSO } from "@/lib/constants"; -import { getIsFreshInstance } from "@/lib/instance/service"; -import { verifyInviteToken } from "@/lib/jwt"; -import { createMembership } from "@/lib/membership/service"; -import { capturePostHogEvent } from "@/lib/posthog"; -import { findMatchingLocale } from "@/lib/utils/locale"; -import { redactPII } from "@/lib/utils/logger-helpers"; -import { createBrevoCustomer } from "@/modules/auth/lib/brevo"; -import { createUser, getUserByEmail, updateUser } from "@/modules/auth/lib/user"; -import { getIsValidInviteToken } from "@/modules/auth/signup/lib/invite"; -import { TOidcNameFields, TSamlNameFields } from "@/modules/auth/types/auth"; -import { - getAccessControlPermission, - getIsMultiOrgEnabled, - getIsSamlSsoEnabled, - getIsSsoEnabled, -} from "@/modules/ee/license-check/lib/utils"; -import { getFirstOrganization } from "@/modules/ee/sso/lib/organization"; -import { createDefaultTeamMembership, getOrganizationByTeamId } from "@/modules/ee/sso/lib/team"; -import { LINKED_SSO_LOOKUP_SELECT, TSsoLookupUser, syncSsoIdentityForUser } from "./account-linking"; -import { getSsoProviderLookupCandidates, normalizeSsoProvider } from "./provider-normalization"; -import { startSsoRecovery } from "./sso-recovery"; - -const syncLinkedSsoUser = async ({ - linkedUser, - user, - account, - provider, - contextLogger, - logSource, - legacyAccountIdToNormalize, -}: { - linkedUser: Pick; - user: TUser; - account: Account; - provider: IdentityProvider; - contextLogger: ReturnType; - logSource: "account_row" | "legacy_account_alias" | "legacy_identity_provider"; - legacyAccountIdToNormalize?: string; -}) => { - contextLogger.debug( - { - linkedUserId: linkedUser.id, - emailMatches: linkedUser.email === user.email, - logSource, - }, - "Found existing linked SSO user" - ); - - if (linkedUser.email === user.email) { - await syncSsoIdentityForUser({ - userId: linkedUser.id, - provider, - account: { - type: account.type, - provider, - providerAccountId: account.providerAccountId, - access_token: account.access_token, - refresh_token: account.refresh_token, - expires_at: account.expires_at, - scope: account.scope, - token_type: account.token_type, - id_token: account.id_token, - }, - legacyAccountIdToNormalize, - }); - - contextLogger.debug( - { linkedUserId: linkedUser.id, logSource }, - "SSO callback successful: linked user, email matches" - ); - return true; - } - - contextLogger.debug( - { linkedUserId: linkedUser.id, logSource }, - "Email changed in SSO provider, checking for conflicts" - ); - - const otherUserWithEmail = await getUserByEmail(user.email); - - if (!otherUserWithEmail) { - contextLogger.debug( - { linkedUserId: linkedUser.id, action: "email_update", logSource }, - "No other user with this email found, updating linked user email after SSO provider change" - ); - - await prisma.$transaction(async (tx) => { - await tx.user.update({ - where: { - id: linkedUser.id, - }, - data: { - email: user.email, - }, - }); - - await syncSsoIdentityForUser({ - userId: linkedUser.id, - provider, - account: { - type: account.type, - provider, - providerAccountId: account.providerAccountId, - access_token: account.access_token, - refresh_token: account.refresh_token, - expires_at: account.expires_at, - scope: account.scope, - token_type: account.token_type, - id_token: account.id_token, - }, - tx, - legacyAccountIdToNormalize, - }); - }); - - return true; - } - - contextLogger.debug( - { linkedUserId: linkedUser.id, conflictingUserId: otherUserWithEmail.id, logSource }, - "SSO callback failed: email conflict after provider change" - ); - - throw new Error( - "Looks like you updated your email somewhere else. A user with this new email exists already." - ); -}; - -const findLinkedSsoUser = async ({ - provider, - providerAccountId, -}: { - provider: IdentityProvider; - providerAccountId: string; -}): Promise<{ - linkedUser: TSsoLookupUser; - logSource: "account_row" | "legacy_account_alias"; - legacyAccountIdToNormalize?: string; -} | null> => { - const lookupCandidates = getSsoProviderLookupCandidates(provider); - - for (const lookupProvider of lookupCandidates) { - const existingLinkedAccount = await prisma.account.findUnique({ - where: { - provider_providerAccountId: { - provider: lookupProvider, - providerAccountId, - }, - }, - select: { - id: true, - provider: true, - user: { - select: LINKED_SSO_LOOKUP_SELECT, - }, - }, - }); - - if (!existingLinkedAccount?.user) { - continue; - } - - if (existingLinkedAccount.provider === provider) { - return { - linkedUser: existingLinkedAccount.user, - logSource: "account_row", - }; - } - - return { - linkedUser: existingLinkedAccount.user, - logSource: "legacy_account_alias", - legacyAccountIdToNormalize: existingLinkedAccount.id, - }; - } - - return null; -}; - -const findLegacyExactMatch = async ({ - provider, - providerAccountId, -}: { - provider: IdentityProvider; - providerAccountId: string; -}) => - prisma.user.findFirst({ - where: { - identityProvider: provider, - identityProviderAccountId: providerAccountId, - }, - select: LINKED_SSO_LOOKUP_SELECT, - }); - -const provisionNewSsoUser = async ({ - user, - account, - provider, - callbackUrl, - contextLogger, -}: { - user: TUser; - account: Account; - provider: IdentityProvider; - callbackUrl: string; - contextLogger: ReturnType; -}) => { - let userName = user.name; - - if (provider === "openid") { - const oidcUser = user as TUser & TOidcNameFields; - if (oidcUser.name) { - userName = oidcUser.name; - } else if (oidcUser.given_name || oidcUser.family_name) { - userName = `${oidcUser.given_name} ${oidcUser.family_name}`; - } else if (oidcUser.preferred_username) { - userName = oidcUser.preferred_username; - } - - contextLogger.debug( - { - hasName: !!oidcUser.name, - hasGivenName: !!oidcUser.given_name, - hasFamilyName: !!oidcUser.family_name, - hasPreferredUsername: !!oidcUser.preferred_username, - }, - "Extracted OIDC user name" - ); - } - - if (provider === "saml") { - const samlUser = user as TUser & TSamlNameFields; - if (samlUser.name) { - userName = samlUser.name; - } else if (samlUser.firstName || samlUser.lastName) { - userName = `${samlUser.firstName} ${samlUser.lastName}`; - } - contextLogger.debug( - { - hasName: !!samlUser.name, - hasFirstName: !!samlUser.firstName, - hasLastName: !!samlUser.lastName, - }, - "Extracted SAML user name" - ); - } - - const isMultiOrgEnabled = await getIsMultiOrgEnabled(); - const isFirstUser = await getIsFreshInstance(); - - contextLogger.debug( - { - isMultiOrgEnabled, - isFirstUser, - skipInviteForSso: SKIP_INVITE_FOR_SSO, - hasDefaultTeamId: !!DEFAULT_TEAM_ID, - }, - "License and instance configuration checked" - ); - - if (!isFirstUser && !isMultiOrgEnabled && SKIP_INVITE_FOR_SSO && !DEFAULT_TEAM_ID) { - contextLogger.error( - { reason: "missing_default_team_id" }, - "SSO callback rejected: AUTH_SKIP_INVITE_FOR_SSO is enabled but AUTH_SSO_DEFAULT_TEAM_ID is not configured. Refusing to auto-provision new SSO user into an arbitrary organization." - ); - return false; - } - - if (!isFirstUser && !SKIP_INVITE_FOR_SSO && !isMultiOrgEnabled) { - if (!callbackUrl) { - contextLogger.debug( - { reason: "missing_callback_url" }, - "SSO callback rejected: missing callback URL for invite validation" - ); - return false; - } - - try { - const isValidCallbackUrl = new URL(callbackUrl); - const inviteToken = isValidCallbackUrl.searchParams.get("token") || ""; - const source = isValidCallbackUrl.searchParams.get("source") || ""; - - if (source === "signin" && !inviteToken) { - contextLogger.debug( - { reason: "signin_without_invite_token" }, - "SSO callback rejected: signin without invite token" - ); - return false; - } - - const { email, inviteId } = verifyInviteToken(inviteToken); - if (email !== user.email) { - contextLogger.debug( - { reason: "invite_email_mismatch", inviteId }, - "SSO callback rejected: invite token email mismatch" - ); - return false; - } - - const isValidInviteToken = await getIsValidInviteToken(inviteId); - if (!isValidInviteToken) { - contextLogger.debug( - { reason: "invalid_invite_token", inviteId }, - "SSO callback rejected: invalid or expired invite token" - ); - return false; - } - contextLogger.debug({ inviteId }, "Invite token validation successful"); - } catch (err) { - contextLogger.debug( - { - reason: "invite_token_validation_error", - error: err instanceof Error ? err.message : "unknown_error", - }, - "SSO callback rejected: invite token validation failed" - ); - contextLogger.error(err, "Invalid callbackUrl"); - return false; - } - } - - let organization: Organization | null = null; - - if (!isFirstUser && !isMultiOrgEnabled) { - contextLogger.debug( - { - assignmentStrategy: SKIP_INVITE_FOR_SSO && DEFAULT_TEAM_ID ? "default_team" : "first_organization", - }, - "Determining organization assignment" - ); - if (SKIP_INVITE_FOR_SSO && DEFAULT_TEAM_ID) { - organization = await getOrganizationByTeamId(DEFAULT_TEAM_ID); - } else { - organization = await getFirstOrganization(); - } - - if (!organization) { - contextLogger.debug( - { reason: "no_organization_found" }, - "SSO callback rejected: no organization found for assignment" - ); - return false; - } - - const isAccessControlAllowed = await getAccessControlPermission(organization.id); - if (!isAccessControlAllowed && !callbackUrl) { - contextLogger.debug( - { - reason: "insufficient_role_permissions", - organizationId: organization.id, - isAccessControlAllowed, - }, - "SSO callback rejected: insufficient role management permissions" - ); - return false; - } - } - - contextLogger.debug({ hasUserName: !!userName, identityProvider: provider }, "Creating new SSO user"); - const matchedLocale = await findMatchingLocale(); - - const userProfile = await prisma.$transaction(async (tx) => { - const createdUser = await createUser( - { - name: - userName || - user.email - .split("@")[0] - .replace(/[^'\p{L}\p{M}\s\d-]+/gu, " ") - .trim(), - email: user.email, - emailVerified: true, - identityProvider: provider, - identityProviderAccountId: account.providerAccountId, - locale: matchedLocale, - }, - tx - ); - - await syncSsoIdentityForUser({ - userId: createdUser.id, - provider, - account: { - type: account.type, - provider, - providerAccountId: account.providerAccountId, - access_token: account.access_token, - refresh_token: account.refresh_token, - expires_at: account.expires_at, - scope: account.scope, - token_type: account.token_type, - id_token: account.id_token, - }, - tx, - }); - - if (organization) { - contextLogger.debug( - { newUserId: createdUser.id, organizationId: organization.id, role: "member" }, - "Assigning user to organization" - ); - await createMembership(organization.id, createdUser.id, { role: "member", accepted: true }, tx); - - if (SKIP_INVITE_FOR_SSO && DEFAULT_TEAM_ID) { - contextLogger.debug( - { newUserId: createdUser.id, defaultTeamId: DEFAULT_TEAM_ID }, - "Creating default team membership" - ); - await createDefaultTeamMembership(createdUser.id, tx); - } - - const updatedNotificationSettings: TUserNotificationSettings = { - ...createdUser.notificationSettings, - alert: { - ...createdUser.notificationSettings?.alert, - }, - unsubscribedOrganizationIds: Array.from( - new Set([...(createdUser.notificationSettings?.unsubscribedOrganizationIds || []), organization.id]) - ), - }; - - await updateUser( - createdUser.id, - { - notificationSettings: updatedNotificationSettings, - }, - tx - ); - } - - return createdUser; - }); - - contextLogger.debug( - { newUserId: userProfile.id, identityProvider: provider }, - "New SSO user created successfully" - ); - - createBrevoCustomer({ id: userProfile.id, email: userProfile.email }); - - capturePostHogEvent(userProfile.id, "user_signed_up", { - auth_provider: provider, - email_domain: userProfile.email.split("@")[1], - signup_source: callbackUrl?.includes("token=") ? "invite" : "direct", - invite_organization_id: organization?.id ?? null, - }); - - if (isMultiOrgEnabled) { - contextLogger.debug( - { isMultiOrgEnabled, newUserId: userProfile.id }, - "Multi-org enabled, skipping organization assignment" - ); - return true; - } - - if (organization) { - return true; - } - - return true; -}; - -export const handleSsoCallback = async ({ - user, - account, - callbackUrl, -}: { - user: TUser; - account: Account; - callbackUrl: string; -}): Promise => { - const contextLogger = logger.withContext({ - correlationId: crypto.randomUUID(), - name: "formbricks", - }); - - contextLogger.debug( - { - ...redactPII({ user, account, callbackUrl }), - hasEmail: !!user.email, - hasName: !!user.name, - }, - "SSO callback initiated" - ); - - const isSsoEnabled = await getIsSsoEnabled(); - if (!isSsoEnabled) { - contextLogger.debug({ isSsoEnabled }, "SSO not enabled"); - return false; - } - - if (!user.email || account.type !== "oauth") { - contextLogger.debug( - { - hasEmail: !!user.email, - accountType: account.type, - reason: !user.email ? "missing_email" : "invalid_account_type", - }, - "SSO callback rejected: missing email or invalid account type" - ); - - return false; - } - - const provider = normalizeSsoProvider(account.provider); - if (!provider) { - contextLogger.debug({ provider: account.provider }, "SSO callback rejected: unsupported provider"); - return false; - } - - if (provider === "saml") { - const isSamlSsoEnabled = await getIsSamlSsoEnabled(); - if (!isSamlSsoEnabled) { - contextLogger.debug({ provider: "saml" }, "SSO callback rejected: SAML not enabled in license"); - return false; - } - } - - contextLogger.debug( - { lookupType: "account_provider_account_id" }, - "Checking for existing linked user by provider account" - ); - const existingLinkedUser = await findLinkedSsoUser({ - provider, - providerAccountId: account.providerAccountId, - }); - - if (existingLinkedUser) { - return syncLinkedSsoUser({ - linkedUser: existingLinkedUser.linkedUser, - user, - account, - provider, - contextLogger, - logSource: existingLinkedUser.logSource, - legacyAccountIdToNormalize: existingLinkedUser.legacyAccountIdToNormalize, - }); - } - - contextLogger.debug( - { lookupType: "legacy_identity_provider_account_id" }, - "No account row found, checking for legacy linked SSO user" - ); - const legacyExactMatch = await findLegacyExactMatch({ - provider, - providerAccountId: account.providerAccountId, - }); - - if (legacyExactMatch) { - return syncLinkedSsoUser({ - linkedUser: legacyExactMatch, - user, - account, - provider, - contextLogger, - logSource: "legacy_identity_provider", - }); - } - - contextLogger.debug({ lookupType: "email" }, "No linked SSO account found, checking for user by email"); - const existingUserWithEmail = await prisma.user.findUnique({ - where: { - email: user.email, - }, - select: LINKED_SSO_LOOKUP_SELECT, - }); - - if (existingUserWithEmail) { - contextLogger.debug( - { - existingUserId: existingUserWithEmail.id, - existingIdentityProvider: existingUserWithEmail.identityProvider, - }, - "SSO callback requires inbox verification before linking" - ); - - return startSsoRecovery({ - existingUser: existingUserWithEmail, - provider, - account, - callbackUrl, - }); - } - - contextLogger.debug( - { action: "new_user_creation" }, - "No existing user found, proceeding with new user creation" - ); - - return provisionNewSsoUser({ - user, - account, - provider, - callbackUrl, - contextLogger, - }); -}; diff --git a/apps/web/modules/ee/sso/lib/sso-provisioning.ts b/apps/web/modules/ee/sso/lib/sso-provisioning.ts index 1520e5dade67..d9b0ac4fffe1 100644 --- a/apps/web/modules/ee/sso/lib/sso-provisioning.ts +++ b/apps/web/modules/ee/sso/lib/sso-provisioning.ts @@ -29,8 +29,8 @@ export type TSsoProvisioningDecision = /** * Validates the invite token carried on an SSO callback URL (only consulted when invites aren't * skipped). Returns a rejection reason, or null when the invite is valid. Extracted from - * gateSsoProvisioning so that gate stays under the cognitive-complexity budget — the parity logic - * (pinned by sso-handlers.test.ts) is unchanged. + * gateSsoProvisioning so that gate stays under the cognitive-complexity budget — its behavior is + * covered by sso-provisioning.test.ts. */ const validateSsoInviteToken = async (email: string, callbackUrl: string): Promise => { if (!callbackUrl) return "missing_callback_url"; @@ -60,8 +60,8 @@ const validateSsoInviteToken = async (email: string, callbackUrl: string): Promi }; /** - * Gate for SSO just-in-time user provisioning — the orphan-safe, WRITE-FREE decision logic mirrored - * from `provisionNewSsoUser` (sso-handlers.ts:254-363). + * Gate for SSO just-in-time user provisioning — the orphan-safe, WRITE-FREE decision logic for the + * Better Auth SSO sign-up flow (introduced by the NextAuth→Better Auth migration, ENG-1054). * * MUST be called from `databaseHooks.user.create.before`, which Better Auth runs INSIDE the * user+account transaction: a `"reject"` there → return `false` → the row rolls back, so no orphan @@ -69,7 +69,7 @@ const validateSsoInviteToken = async (email: string, callbackUrl: string): Promi * `"provision"` decision (resolved org + flags) is carried to the after-hook, which performs the * membership writes. * - * Parity invariants (pinned by sso-handlers.test.ts): fresh-instance & multi-org bypass all gates; + * Invariants (covered by sso-provisioning.test.ts): fresh-instance & multi-org bypass all gates; * single-org + `SKIP_INVITE_FOR_SSO` requires `DEFAULT_TEAM_ID`; otherwise a valid invite token * matching the email is required; the assignment org is the default team's org (skip-invite) or the * first org; access control without a callback URL is refused. @@ -137,7 +137,8 @@ export const gateSsoProvisioning = async ({ }; /** - * Provisioning WRITES for a newly created SSO user — mirrors provisionNewSsoUser:404-452. Called from + * Provisioning WRITES for a newly created SSO user — mirrors the legacy NextAuth SSO provisioning + * writes. Called from * `databaseHooks.user.create.after` (post-commit), so it CANNOT share Better Auth's user/account * transaction (design doc §13). It runs its own transaction and is idempotent + best-effort: * `createMembership`/`createDefaultTeamMembership` upsert, and a failure is retried once then logged diff --git a/apps/web/modules/ee/sso/lib/tests/__mock__/sso-handlers.mock.ts b/apps/web/modules/ee/sso/lib/tests/__mock__/sso-handlers.mock.ts deleted file mode 100644 index 510f235be089..000000000000 --- a/apps/web/modules/ee/sso/lib/tests/__mock__/sso-handlers.mock.ts +++ /dev/null @@ -1,87 +0,0 @@ -import type { Account } from "@formbricks/types/auth"; -import type { TOrganization } from "@formbricks/types/organizations"; -import type { TUser } from "@formbricks/types/user"; - -// Mock user data -export const mockUser: TUser = { - id: "user-123", - email: "test@example.com", - name: "Test User", - notificationSettings: { - alert: {}, - - unsubscribedOrganizationIds: [], - }, - emailVerified: true, - twoFactorEnabled: false, - identityProvider: "google", - locale: "en-US", - createdAt: new Date(), - updatedAt: new Date(), - lastLoginAt: new Date(), - isActive: true, -}; - -// Mock account data -export const mockAccount: Account = { - provider: "google", - type: "oauth", - providerAccountId: "provider-123", -}; - -// Mock OpenID account -export const mockOpenIdAccount: Account = { - ...mockAccount, - provider: "openid", -}; - -// Mock SAML account -export const mockSamlAccount: Account = { - ...mockAccount, - provider: "saml", -}; - -// Mock organization data -export const mockOrganization: TOrganization = { - id: "org-123", - name: "Test Organization", - isAISmartToolsEnabled: false, - whitelabel: { - logoUrl: null, - faviconUrl: null, - }, - billing: { - stripeCustomerId: null, - limits: { monthly: { responses: null }, workspaces: null }, - usageCycleAnchor: new Date(), - }, - createdAt: new Date(), - updatedAt: new Date(), -}; - -// Mock user with OpenID fields -export const mockOpenIdUser = (options?: { - name?: string; - given_name?: string; - family_name?: string; - preferred_username?: string; - email?: string; -}): TUser & { - given_name?: string; - family_name?: string; - preferred_username?: string; -} => ({ - ...mockUser, - name: options?.name || "", - given_name: options?.given_name, - family_name: options?.family_name, - preferred_username: options?.preferred_username, - email: options?.email || mockUser.email, -}); - -// Mock created user response -export const mockCreatedUser = (name: string = mockUser.name): TUser => ({ - ...mockUser, - name, - emailVerified: true, -}); diff --git a/apps/web/modules/ee/sso/lib/tests/sso-handlers.test.ts b/apps/web/modules/ee/sso/lib/tests/sso-handlers.test.ts deleted file mode 100644 index 9dbd116ec51b..000000000000 --- a/apps/web/modules/ee/sso/lib/tests/sso-handlers.test.ts +++ /dev/null @@ -1,894 +0,0 @@ -import { beforeEach, describe, expect, test, vi } from "vitest"; -import { prisma } from "@formbricks/database"; -import { Organization } from "@formbricks/database/prisma"; -import { getIsFreshInstance } from "@/lib/instance/service"; -import { verifyInviteToken } from "@/lib/jwt"; -import { createMembership } from "@/lib/membership/service"; -import { capturePostHogEvent } from "@/lib/posthog"; -import { findMatchingLocale } from "@/lib/utils/locale"; -import { createBrevoCustomer } from "@/modules/auth/lib/brevo"; -import { createUser, getUserByEmail, updateUser } from "@/modules/auth/lib/user"; -import { getIsValidInviteToken } from "@/modules/auth/signup/lib/invite"; -import { - getAccessControlPermission, - getIsMultiOrgEnabled, - getIsSamlSsoEnabled, - getIsSsoEnabled, -} from "@/modules/ee/license-check/lib/utils"; -import { getFirstOrganization } from "@/modules/ee/sso/lib/organization"; -import { startSsoRecovery } from "@/modules/ee/sso/lib/sso-recovery"; -import { createDefaultTeamMembership, getOrganizationByTeamId } from "@/modules/ee/sso/lib/team"; -import { handleSsoCallback } from "../sso-handlers"; -import { - mockAccount, - mockCreatedUser, - mockOpenIdUser, - mockOrganization, - mockSamlAccount, - mockUser, -} from "./__mock__/sso-handlers.mock"; - -vi.mock("@/modules/auth/lib/brevo", () => ({ - createBrevoCustomer: vi.fn(), -})); - -vi.mock("@/modules/auth/lib/user", () => ({ - getUserByEmail: vi.fn(), - updateUser: vi.fn(), - createUser: vi.fn(), -})); - -vi.mock("@/modules/auth/signup/lib/invite", () => ({ - getIsValidInviteToken: vi.fn(), -})); - -vi.mock("@/modules/ee/license-check/lib/utils", () => ({ - getIsSamlSsoEnabled: vi.fn(), - getIsSsoEnabled: vi.fn(), - getAccessControlPermission: vi.fn(), - getIsMultiOrgEnabled: vi.fn(), -})); - -vi.mock("@formbricks/database", () => ({ - prisma: { - $transaction: vi.fn( - async (callback: (tx: any) => unknown) => - await callback({ - account: { - create: vi.fn(), - delete: vi.fn(), - findUnique: vi.fn(), - update: vi.fn(), - }, - user: { - update: vi.fn(), - }, - }) - ), - account: { - create: vi.fn(), - delete: vi.fn(), - findUnique: vi.fn(), - update: vi.fn(), - }, - user: { - findFirst: vi.fn(), - findUnique: vi.fn(), - update: vi.fn(), - }, - }, -})); - -vi.mock("@/lib/instance/service", () => ({ - getIsFreshInstance: vi.fn(), -})); - -vi.mock("@/modules/ee/sso/lib/organization", () => ({ - getFirstOrganization: vi.fn(), -})); - -vi.mock("@/modules/ee/sso/lib/team", () => ({ - getOrganizationByTeamId: vi.fn(), - createDefaultTeamMembership: vi.fn(), -})); - -vi.mock("@/lib/membership/service", () => ({ - createMembership: vi.fn(), -})); - -vi.mock("@/lib/utils/locale", () => ({ - findMatchingLocale: vi.fn(), -})); - -vi.mock("@/lib/jwt", () => ({ - verifyInviteToken: vi.fn(), -})); - -vi.mock("@/modules/ee/sso/lib/sso-recovery", () => ({ - startSsoRecovery: vi.fn(), -})); - -vi.mock("@formbricks/logger", () => ({ - logger: { - error: vi.fn(), - debug: vi.fn(), - withContext: vi.fn(() => ({ - debug: vi.fn(), - error: vi.fn(), - info: vi.fn(), - })), - }, -})); - -const constantsOverrides = vi.hoisted(() => ({ - SKIP_INVITE_FOR_SSO: false as boolean, - DEFAULT_TEAM_ID: "team-123" as string | undefined, -})); - -vi.mock("@/lib/constants", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - get SKIP_INVITE_FOR_SSO() { - return constantsOverrides.SKIP_INVITE_FOR_SSO; - }, - get DEFAULT_TEAM_ID() { - return constantsOverrides.DEFAULT_TEAM_ID; - }, - }; -}); - -vi.mock("@/lib/posthog", () => ({ - capturePostHogEvent: vi.fn(), -})); - -const transactionAccount = { - create: vi.fn(), - delete: vi.fn(), - findUnique: vi.fn(), - update: vi.fn(), -}; - -const transactionUser = { - update: vi.fn(), -}; - -describe("handleSsoCallback", () => { - beforeEach(() => { - vi.clearAllMocks(); - constantsOverrides.SKIP_INVITE_FOR_SSO = false; - constantsOverrides.DEFAULT_TEAM_ID = "team-123"; - - vi.mocked(prisma.$transaction).mockImplementation( - async (callback: (tx: any) => unknown) => - await callback({ - account: transactionAccount, - user: transactionUser, - }) - ); - - vi.mocked(getIsSsoEnabled).mockResolvedValue(true); - vi.mocked(getIsSamlSsoEnabled).mockResolvedValue(true); - vi.mocked(findMatchingLocale).mockResolvedValue("en-US"); - vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(true); - vi.mocked(getIsFreshInstance).mockResolvedValue(true); - vi.mocked(getUserByEmail).mockResolvedValue(null); - vi.mocked(updateUser).mockResolvedValue({ ...mockUser, id: "user-123" }); - vi.mocked(createDefaultTeamMembership).mockResolvedValue(undefined); - vi.mocked(createMembership).mockResolvedValue({ - role: "member", - accepted: true, - userId: mockUser.id, - organizationId: mockOrganization.id, - }); - vi.mocked(getFirstOrganization).mockResolvedValue(mockOrganization as unknown as Organization); - vi.mocked(getOrganizationByTeamId).mockResolvedValue(mockOrganization as unknown as Organization); - vi.mocked(getAccessControlPermission).mockResolvedValue(true); - vi.mocked(startSsoRecovery).mockResolvedValue("/auth/verification-requested?token=email-token"); - vi.mocked(getIsValidInviteToken).mockResolvedValue(true); - vi.mocked(verifyInviteToken).mockReturnValue({ - email: mockUser.email, - inviteId: "invite-123", - } as any); - transactionAccount.findUnique.mockResolvedValue(null); - transactionAccount.create.mockResolvedValue(undefined); - transactionAccount.update.mockResolvedValue(undefined); - transactionAccount.delete.mockResolvedValue(undefined); - transactionUser.update.mockResolvedValue(undefined); - }); - - test("returns false when SSO is disabled", async () => { - vi.mocked(getIsSsoEnabled).mockResolvedValue(false); - - const result = await handleSsoCallback({ - user: mockUser, - account: mockAccount, - callbackUrl: "http://localhost:3000", - }); - - expect(result).toBe(false); - }); - - test("syncs an existing canonical account link when the provider account already exists", async () => { - vi.mocked(prisma.account.findUnique) - .mockResolvedValueOnce({ - id: "account_1", - provider: "google", - user: { - ...mockUser, - email: mockUser.email, - }, - } as any) - .mockResolvedValueOnce(null); - transactionAccount.findUnique.mockResolvedValue({ - id: "account_1", - userId: mockUser.id, - } as any); - - const result = await handleSsoCallback({ - user: mockUser, - account: mockAccount, - callbackUrl: "http://localhost:3000", - }); - - expect(result).toBe(true); - expect(transactionAccount.update).toHaveBeenCalledWith({ - where: { - id: "account_1", - }, - data: {}, - }); - expect(transactionUser.update).toHaveBeenCalledWith({ - where: { - id: mockUser.id, - }, - data: { - identityProvider: "google", - identityProviderAccountId: mockAccount.providerAccountId, - }, - }); - }); - - test("normalizes legacy Azure account aliases into the canonical provider id", async () => { - const azureAccount = { ...mockAccount, provider: "azuread" }; - - vi.mocked(prisma.account.findUnique) - .mockResolvedValueOnce(null) - .mockResolvedValueOnce({ - id: "legacy_account_1", - provider: "azure-ad", - user: { - ...mockUser, - email: mockUser.email, - }, - } as any) - .mockResolvedValueOnce(null); - transactionAccount.findUnique.mockResolvedValue(null); - - const result = await handleSsoCallback({ - user: mockUser, - account: azureAccount, - callbackUrl: "http://localhost:3000", - }); - - expect(result).toBe(true); - expect(transactionAccount.update).toHaveBeenCalledWith({ - where: { - id: "legacy_account_1", - }, - data: { - userId: mockUser.id, - type: "oauth", - provider: "azuread", - providerAccountId: mockAccount.providerAccountId, - }, - }); - }); - - test("updates the linked SSO user email when the provider email changes without conflicts", async () => { - vi.mocked(prisma.account.findUnique).mockResolvedValueOnce({ - id: "account_1", - provider: "google", - user: { - ...mockUser, - id: "linked-user-1", - email: "old@example.com", - }, - } as any); - transactionAccount.findUnique.mockResolvedValue({ - id: "account_1", - userId: "linked-user-1", - } as any); - - const result = await handleSsoCallback({ - user: { - ...mockUser, - email: "new@example.com", - }, - account: mockAccount, - callbackUrl: "http://localhost:3000", - }); - - expect(result).toBe(true); - expect(transactionUser.update).toHaveBeenNthCalledWith(1, { - where: { - id: "linked-user-1", - }, - data: { - email: "new@example.com", - }, - }); - expect(transactionUser.update).toHaveBeenNthCalledWith(2, { - where: { - id: "linked-user-1", - }, - data: { - identityProvider: "google", - identityProviderAccountId: mockAccount.providerAccountId, - }, - }); - }); - - test("rejects sign-in when the provider email changes to an email that is already taken", async () => { - vi.mocked(prisma.account.findUnique).mockResolvedValueOnce({ - id: "account_1", - provider: "google", - user: { - ...mockUser, - id: "linked-user-1", - email: "old@example.com", - }, - } as any); - vi.mocked(getUserByEmail).mockResolvedValueOnce({ - ...mockUser, - id: "conflict-user-1", - email: "new@example.com", - } as any); - - await expect( - handleSsoCallback({ - user: { - ...mockUser, - email: "new@example.com", - }, - account: mockAccount, - callbackUrl: "http://localhost:3000", - }) - ).rejects.toThrow("Looks like you updated your email somewhere else."); - - expect(transactionUser.update).not.toHaveBeenCalled(); - }); - - test("backfills a canonical account row from a legacy exact user match", async () => { - vi.mocked(prisma.account.findUnique).mockResolvedValue(null); - vi.mocked(prisma.user.findFirst).mockResolvedValue({ - ...mockUser, - identityProvider: "google", - identityProviderAccountId: mockAccount.providerAccountId, - } as any); - transactionAccount.findUnique.mockResolvedValue(null); - - const result = await handleSsoCallback({ - user: mockUser, - account: mockAccount, - callbackUrl: "http://localhost:3000", - }); - - expect(result).toBe(true); - expect(transactionAccount.create).toHaveBeenCalledWith({ - data: { - userId: mockUser.id, - type: "oauth", - provider: "google", - providerAccountId: mockAccount.providerAccountId, - }, - }); - expect(transactionUser.update).toHaveBeenCalledWith({ - where: { - id: mockUser.id, - }, - data: { - identityProvider: "google", - identityProviderAccountId: mockAccount.providerAccountId, - }, - }); - }); - - test("starts inbox-based recovery for an existing same-email user without a linked account", async () => { - vi.mocked(prisma.account.findUnique).mockResolvedValue(null); - vi.mocked(prisma.user.findFirst).mockResolvedValue(null); - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - ...mockUser, - identityProvider: "email", - identityProviderAccountId: null, - emailVerified: new Date(), - } as any); - - const result = await handleSsoCallback({ - user: mockUser, - account: mockAccount, - callbackUrl: "http://localhost:3000/invite?token=invite-token", - }); - - expect(result).toBe("/auth/verification-requested?token=email-token"); - expect(startSsoRecovery).toHaveBeenCalledWith({ - existingUser: expect.objectContaining({ - id: mockUser.id, - email: mockUser.email, - }), - provider: "google", - account: mockAccount, - callbackUrl: "http://localhost:3000/invite?token=invite-token", - }); - expect(createUser).not.toHaveBeenCalled(); - expect(createMembership).not.toHaveBeenCalled(); - expect(createBrevoCustomer).not.toHaveBeenCalled(); - expect(capturePostHogEvent).not.toHaveBeenCalled(); - }); - - test("keeps unverified email-password users in the recovery flow instead of activating them during SSO", async () => { - vi.mocked(prisma.account.findUnique).mockResolvedValue(null); - vi.mocked(prisma.user.findFirst).mockResolvedValue(null); - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - ...mockUser, - identityProvider: "email", - identityProviderAccountId: null, - emailVerified: null, - } as any); - - const result = await handleSsoCallback({ - user: mockUser, - account: mockAccount, - callbackUrl: "http://localhost:3000/invite?token=invite-token", - }); - - expect(result).toBe("/auth/verification-requested?token=email-token"); - expect(startSsoRecovery).toHaveBeenCalledWith({ - existingUser: expect.objectContaining({ - id: mockUser.id, - email: mockUser.email, - emailVerified: null, - identityProvider: "email", - }), - provider: "google", - account: mockAccount, - callbackUrl: "http://localhost:3000/invite?token=invite-token", - }); - expect(createUser).not.toHaveBeenCalled(); - }); - - test("starts recovery for a legacy SSO-only user when the stored provider account id is stale", async () => { - vi.mocked(prisma.account.findUnique).mockResolvedValue(null); - vi.mocked(prisma.user.findFirst).mockResolvedValue(null); - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - ...mockUser, - identityProvider: "google", - identityProviderAccountId: "legacy-google-subject", - emailVerified: new Date(), - password: null, - } as any); - - const result = await handleSsoCallback({ - user: mockUser, - account: { - ...mockAccount, - provider: "google", - providerAccountId: "new-google-subject", - }, - callbackUrl: "http://localhost:3000", - }); - - expect(result).toBe("/auth/verification-requested?token=email-token"); - expect(startSsoRecovery).toHaveBeenCalledWith({ - existingUser: expect.objectContaining({ - id: mockUser.id, - email: mockUser.email, - identityProvider: "google", - identityProviderAccountId: "legacy-google-subject", - }), - provider: "google", - account: expect.objectContaining({ - provider: "google", - providerAccountId: "new-google-subject", - }), - callbackUrl: "http://localhost:3000", - }); - expect(createUser).not.toHaveBeenCalled(); - }); - - test("creates a new SSO user with canonical provider state when no existing user is found", async () => { - vi.mocked(prisma.account.findUnique).mockResolvedValue(null); - vi.mocked(prisma.user.findFirst).mockResolvedValue(null); - vi.mocked(prisma.user.findUnique).mockResolvedValue(null); - vi.mocked(createUser).mockResolvedValue(mockCreatedUser()); - transactionAccount.findUnique.mockResolvedValue(null); - - const result = await handleSsoCallback({ - user: mockUser, - account: mockAccount, - callbackUrl: "http://localhost:3000", - }); - - expect(result).toBe(true); - expect(createUser).toHaveBeenCalledWith( - { - name: mockUser.name, - email: mockUser.email, - emailVerified: true, - identityProvider: "google", - identityProviderAccountId: mockAccount.providerAccountId, - locale: "en-US", - }, - expect.anything() - ); - expect(createBrevoCustomer).toHaveBeenCalledWith({ id: mockUser.id, email: mockUser.email }); - expect(capturePostHogEvent).toHaveBeenCalledWith(mockUser.id, "user_signed_up", { - auth_provider: "google", - email_domain: "example.com", - signup_source: "direct", - invite_organization_id: null, - }); - }); - - test("extracts fallback OpenID names when direct name is missing", async () => { - vi.mocked(prisma.account.findUnique).mockResolvedValue(null); - vi.mocked(prisma.user.findFirst).mockResolvedValue(null); - vi.mocked(prisma.user.findUnique).mockResolvedValue(null); - vi.mocked(createUser).mockResolvedValue(mockCreatedUser("John Doe")); - transactionAccount.findUnique.mockResolvedValue(null); - - const openIdUser = mockOpenIdUser({ - given_name: "John", - family_name: "Doe", - }); - - await handleSsoCallback({ - user: openIdUser, - account: { ...mockAccount, provider: "openid" }, - callbackUrl: "http://localhost:3000", - }); - - expect(createUser).toHaveBeenCalledWith( - expect.objectContaining({ - name: "John Doe", - identityProvider: "openid", - }), - expect.anything() - ); - }); - - test("extracts the preferred OpenID username when no other name fields are present", async () => { - vi.mocked(prisma.account.findUnique).mockResolvedValue(null); - vi.mocked(prisma.user.findFirst).mockResolvedValue(null); - vi.mocked(prisma.user.findUnique).mockResolvedValue(null); - vi.mocked(createUser).mockResolvedValue(mockCreatedUser("oidc-handle")); - transactionAccount.findUnique.mockResolvedValue(null); - - await handleSsoCallback({ - user: mockOpenIdUser({ - preferred_username: "oidc-handle", - }), - account: { ...mockAccount, provider: "openid" }, - callbackUrl: "http://localhost:3000", - }); - - expect(createUser).toHaveBeenCalledWith( - expect.objectContaining({ - name: "oidc-handle", - identityProvider: "openid", - }), - expect.anything() - ); - }); - - test("extracts fallback SAML names when the display name is missing", async () => { - vi.mocked(prisma.account.findUnique).mockResolvedValue(null); - vi.mocked(prisma.user.findFirst).mockResolvedValue(null); - vi.mocked(prisma.user.findUnique).mockResolvedValue(null); - vi.mocked(createUser).mockResolvedValue(mockCreatedUser("Saml User")); - transactionAccount.findUnique.mockResolvedValue(null); - - await handleSsoCallback({ - user: { - ...mockUser, - name: "", - firstName: "Saml", - lastName: "User", - } as any, - account: mockSamlAccount, - callbackUrl: "http://localhost:3000", - }); - - expect(createUser).toHaveBeenCalledWith( - expect.objectContaining({ - name: "Saml User", - identityProvider: "saml", - }), - expect.anything() - ); - }); - - test("rejects new SSO sign-up when invite validation requires a callback URL and none is provided", async () => { - vi.mocked(prisma.account.findUnique).mockResolvedValue(null); - vi.mocked(prisma.user.findFirst).mockResolvedValue(null); - vi.mocked(prisma.user.findUnique).mockResolvedValue(null); - vi.mocked(getIsFreshInstance).mockResolvedValue(false); - vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false); - - const result = await handleSsoCallback({ - user: mockUser, - account: mockAccount, - callbackUrl: "", - }); - - expect(result).toBe(false); - expect(createUser).not.toHaveBeenCalled(); - }); - - test("rejects sign-in callback URLs that claim a signin source without an invite token", async () => { - vi.mocked(prisma.account.findUnique).mockResolvedValue(null); - vi.mocked(prisma.user.findFirst).mockResolvedValue(null); - vi.mocked(prisma.user.findUnique).mockResolvedValue(null); - vi.mocked(getIsFreshInstance).mockResolvedValue(false); - vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false); - - const result = await handleSsoCallback({ - user: mockUser, - account: mockAccount, - callbackUrl: "http://localhost:3000/auth/login?source=signin", - }); - - expect(result).toBe(false); - expect(createUser).not.toHaveBeenCalled(); - }); - - test("rejects invite tokens that belong to a different email address", async () => { - vi.mocked(prisma.account.findUnique).mockResolvedValue(null); - vi.mocked(prisma.user.findFirst).mockResolvedValue(null); - vi.mocked(prisma.user.findUnique).mockResolvedValue(null); - vi.mocked(getIsFreshInstance).mockResolvedValue(false); - vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false); - vi.mocked(verifyInviteToken).mockReturnValue({ - email: "someone-else@example.com", - inviteId: "invite-123", - } as any); - - const result = await handleSsoCallback({ - user: mockUser, - account: mockAccount, - callbackUrl: "http://localhost:3000/invite?token=invite-token", - }); - - expect(result).toBe(false); - expect(getIsValidInviteToken).not.toHaveBeenCalled(); - }); - - test("rejects invalid or expired invite tokens during new SSO sign-up", async () => { - vi.mocked(prisma.account.findUnique).mockResolvedValue(null); - vi.mocked(prisma.user.findFirst).mockResolvedValue(null); - vi.mocked(prisma.user.findUnique).mockResolvedValue(null); - vi.mocked(getIsFreshInstance).mockResolvedValue(false); - vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false); - vi.mocked(getIsValidInviteToken).mockResolvedValue(false); - - const result = await handleSsoCallback({ - user: mockUser, - account: mockAccount, - callbackUrl: "http://localhost:3000/invite?token=invite-token", - }); - - expect(result).toBe(false); - expect(createUser).not.toHaveBeenCalled(); - }); - - test("rejects malformed callback URLs during invite validation", async () => { - vi.mocked(prisma.account.findUnique).mockResolvedValue(null); - vi.mocked(prisma.user.findFirst).mockResolvedValue(null); - vi.mocked(prisma.user.findUnique).mockResolvedValue(null); - vi.mocked(getIsFreshInstance).mockResolvedValue(false); - vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false); - - const result = await handleSsoCallback({ - user: mockUser, - account: mockAccount, - callbackUrl: "not-a-valid-url", - }); - - expect(result).toBe(false); - expect(createUser).not.toHaveBeenCalled(); - }); - - test("rejects new SSO sign-up when no organization can be assigned", async () => { - vi.mocked(prisma.account.findUnique).mockResolvedValue(null); - vi.mocked(prisma.user.findFirst).mockResolvedValue(null); - vi.mocked(prisma.user.findUnique).mockResolvedValue(null); - vi.mocked(getIsFreshInstance).mockResolvedValue(false); - vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false); - vi.mocked(getFirstOrganization).mockResolvedValue(null); - - const result = await handleSsoCallback({ - user: mockUser, - account: mockAccount, - callbackUrl: "http://localhost:3000/invite?token=invite-token", - }); - - expect(result).toBe(false); - expect(createUser).not.toHaveBeenCalled(); - }); - - test("rejects auto-provisioning when SKIP_INVITE_FOR_SSO is enabled but DEFAULT_TEAM_ID is missing", async () => { - vi.mocked(prisma.account.findUnique).mockResolvedValue(null); - vi.mocked(prisma.user.findFirst).mockResolvedValue(null); - vi.mocked(prisma.user.findUnique).mockResolvedValue(null); - vi.mocked(getIsFreshInstance).mockResolvedValue(false); - vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false); - constantsOverrides.SKIP_INVITE_FOR_SSO = true; - constantsOverrides.DEFAULT_TEAM_ID = undefined; - - const result = await handleSsoCallback({ - user: mockUser, - account: mockAccount, - callbackUrl: "http://localhost:3000", - }); - - expect(result).toBe(false); - expect(getFirstOrganization).not.toHaveBeenCalled(); - expect(getOrganizationByTeamId).not.toHaveBeenCalled(); - expect(createUser).not.toHaveBeenCalled(); - expect(createMembership).not.toHaveBeenCalled(); - }); - - test("rejects auto-provisioning when DEFAULT_TEAM_ID is empty string", async () => { - vi.mocked(prisma.account.findUnique).mockResolvedValue(null); - vi.mocked(prisma.user.findFirst).mockResolvedValue(null); - vi.mocked(prisma.user.findUnique).mockResolvedValue(null); - vi.mocked(getIsFreshInstance).mockResolvedValue(false); - vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false); - constantsOverrides.SKIP_INVITE_FOR_SSO = true; - constantsOverrides.DEFAULT_TEAM_ID = ""; - - const result = await handleSsoCallback({ - user: mockUser, - account: mockAccount, - callbackUrl: "http://localhost:3000", - }); - - expect(result).toBe(false); - expect(getFirstOrganization).not.toHaveBeenCalled(); - expect(createUser).not.toHaveBeenCalled(); - }); - - test("allows auto-provisioning when SKIP_INVITE_FOR_SSO and DEFAULT_TEAM_ID are both set", async () => { - vi.mocked(prisma.account.findUnique).mockResolvedValue(null); - vi.mocked(prisma.user.findFirst).mockResolvedValue(null); - vi.mocked(prisma.user.findUnique).mockResolvedValue(null); - vi.mocked(getIsFreshInstance).mockResolvedValue(false); - vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false); - constantsOverrides.SKIP_INVITE_FOR_SSO = true; - constantsOverrides.DEFAULT_TEAM_ID = "team-123"; - vi.mocked(createUser).mockResolvedValue( - mockCreatedUser("Auto Provisioned User") as typeof mockUser & { - notificationSettings: { alert: Record; unsubscribedOrganizationIds: string[] }; - } - ); - transactionAccount.findUnique.mockResolvedValue(null); - - const result = await handleSsoCallback({ - user: mockUser, - account: mockAccount, - callbackUrl: "http://localhost:3000", - }); - - expect(result).toBe(true); - expect(getOrganizationByTeamId).toHaveBeenCalledWith("team-123"); - expect(getFirstOrganization).not.toHaveBeenCalled(); - expect(createMembership).toHaveBeenCalledWith( - mockOrganization.id, - mockUser.id, - { role: "member", accepted: true }, - expect.anything() - ); - }); - - test("assigns invited SSO users into the resolved organization and syncs notification settings", async () => { - vi.mocked(prisma.account.findUnique).mockResolvedValue(null); - vi.mocked(prisma.user.findFirst).mockResolvedValue(null); - vi.mocked(prisma.user.findUnique).mockResolvedValue(null); - vi.mocked(getIsFreshInstance).mockResolvedValue(false); - vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false); - vi.mocked(verifyInviteToken).mockReturnValue({ - email: "invited@example.com", - inviteId: "invite-123", - } as any); - vi.mocked(createUser).mockResolvedValue( - mockCreatedUser("Org User") as typeof mockUser & { - notificationSettings: { alert: Record; unsubscribedOrganizationIds: string[] }; - } - ); - transactionAccount.findUnique.mockResolvedValue(null); - - const result = await handleSsoCallback({ - user: { - ...mockUser, - email: "invited@example.com", - }, - account: mockAccount, - callbackUrl: "http://localhost:3000/invite?token=invite-token", - }); - - expect(result).toBe(true); - expect(createMembership).toHaveBeenCalledWith( - mockOrganization.id, - mockUser.id, - { role: "member", accepted: true }, - expect.anything() - ); - expect(updateUser).toHaveBeenCalledWith( - mockUser.id, - { - notificationSettings: { - alert: {}, - unsubscribedOrganizationIds: [mockOrganization.id], - }, - }, - expect.anything() - ); - expect(capturePostHogEvent).toHaveBeenCalledWith(mockUser.id, "user_signed_up", { - auth_provider: "google", - email_domain: "example.com", - signup_source: "invite", - invite_organization_id: mockOrganization.id, - }); - }); - - test("rejects unsupported providers before any database writes happen", async () => { - const result = await handleSsoCallback({ - user: mockUser, - account: { - ...mockAccount, - provider: "twitter", - } as any, - callbackUrl: "http://localhost:3000", - }); - - expect(result).toBe(false); - expect(prisma.account.findUnique).not.toHaveBeenCalled(); - expect(createUser).not.toHaveBeenCalled(); - }); - - test("rejects non-oauth accounts and users without an email address", async () => { - await expect( - handleSsoCallback({ - user: { - ...mockUser, - email: "", - }, - account: mockAccount, - callbackUrl: "http://localhost:3000", - }) - ).resolves.toBe(false); - - await expect( - handleSsoCallback({ - user: mockUser, - account: { - ...mockAccount, - type: "email", - } as any, - callbackUrl: "http://localhost:3000", - }) - ).resolves.toBe(false); - }); - - test("rejects SAML sign-in when the license is disabled", async () => { - vi.mocked(getIsSamlSsoEnabled).mockResolvedValue(false); - - const result = await handleSsoCallback({ - user: mockUser, - account: mockSamlAccount, - callbackUrl: "http://localhost:3000", - }); - - expect(result).toBe(false); - }); -}); diff --git a/apps/web/modules/ee/unify-feedback/topics-subtopics/components/taxonomy-controls.tsx b/apps/web/modules/ee/unify-feedback/topics-subtopics/components/taxonomy-controls.tsx index ea130db6e064..fa2d30e44ddf 100644 --- a/apps/web/modules/ee/unify-feedback/topics-subtopics/components/taxonomy-controls.tsx +++ b/apps/web/modules/ee/unify-feedback/topics-subtopics/components/taxonomy-controls.tsx @@ -95,8 +95,18 @@ export const TaxonomyControls = ({ disabled={!canGenerate} loading={isGenerating} onClick={handleGenerateClick}> - {hasActiveTree ? : } - {hasActiveTree ? t("workspace.unify.taxonomy_regenerate") : t("workspace.unify.taxonomy_generate")} + {isGenerating ? ( + // The Button renders its own spinner while `loading`; show just the label (no Play/Refresh + // icon) so we don't end up with a spinner and a static icon side by side. + t("workspace.unify.taxonomy_generating") + ) : ( + <> + {hasActiveTree ? : } + {hasActiveTree + ? t("workspace.unify.taxonomy_regenerate") + : t("workspace.unify.taxonomy_generate")} + + )} diff --git a/apps/web/modules/ee/unify-feedback/topics-subtopics/components/topics-subtopics-container.tsx b/apps/web/modules/ee/unify-feedback/topics-subtopics/components/topics-subtopics-container.tsx index 9f62bfb5e440..fc36ff554e59 100644 --- a/apps/web/modules/ee/unify-feedback/topics-subtopics/components/topics-subtopics-container.tsx +++ b/apps/web/modules/ee/unify-feedback/topics-subtopics/components/topics-subtopics-container.tsx @@ -5,7 +5,6 @@ import type { TFunction } from "i18next"; import { useEffect, useMemo, useState } from "react"; import toast from "react-hot-toast"; import { useTranslation } from "react-i18next"; -import { getV3ApiErrorMessage } from "@/modules/api/lib/v3-client"; import type { TaxonomyNode, TaxonomyRun } from "@/modules/hub/types"; import { Alert, AlertButton, AlertDescription, AlertTitle } from "@/modules/ui/components/alert"; import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal"; @@ -47,7 +46,9 @@ const runFailureMessageFromCode = (code: TaxonomyRun["error_code"], t: TFunction case "internal_error": return t("workspace.unify.taxonomy_failure_internal_error"); default: - return t("workspace.unify.taxonomy_start_failed"); + // Unknown/absent code (e.g. a future Hub failure code we don't map yet) — a neutral message, + // not the wrong-tense "failed to start" (the run did start, then failed). + return t("workspace.unify.taxonomy_failure_generation_failed"); } }; @@ -90,6 +91,7 @@ export const TopicsSubtopicsContainer = ({ const stateQuery = useTaxonomyState({ workspaceId, scope }); const activeTree = stateQuery.data?.activeTree ?? null; + const stateUnavailable = stateQuery.data?.unavailable ?? false; const activeRunId = activeTree?.run.id ?? null; const runs = useMemo(() => stateQuery.data?.runs ?? [], [stateQuery.data]); const latestRun = runs[0] ?? null; @@ -143,12 +145,17 @@ export const TopicsSubtopicsContainer = ({ }, [runStatus, queryClient, workspaceId, scope]); const isRunning = runningRunId !== null || triggerMutation.isPending; - // The directory scope always has a target, so generation only depends on write access + no active run. - const canGenerate = canWrite && !isRunning; + // A failed run keeps generation enabled (that is the retry path); only block generation while the + // taxonomy service itself is unreachable, since a new run can't succeed then anyway. + const serviceUnavailable = fieldsUnavailable || stateUnavailable; + const canGenerate = canWrite && !isRunning && !serviceUnavailable; const hasActiveTree = Boolean(activeTree?.root?.children?.length); + // Surface the curated, localized failure message keyed on error_code — but only when there is NO + // active tree, so a failed *re*generate doesn't drop an error banner over a taxonomy that still works + // (the toast already gives a transient heads-up). The raw Hub `error` string stays server-side (logs). const runFailure = - latestRun?.status === "failed" - ? (latestRun.error ?? runFailureMessageFromCode(latestRun.error_code, t)) + latestRun?.status === "failed" && !hasActiveTree + ? runFailureMessageFromCode(latestRun.error_code, t) : null; const handleGenerate = () => { @@ -161,8 +168,10 @@ export const TopicsSubtopicsContainer = ({ ? t("workspace.unify.taxonomy_run_in_progress") : t("workspace.unify.taxonomy_run_started") ), - onError: (error) => - toast.error(getV3ApiErrorMessage(error, t("workspace.unify.taxonomy_start_failed"))), + // A trigger failure comes back as a raw upstream detail (502 / bad_gateway with the Hub's JSON + // blob), so show a clean localized message instead of dumping it. The specific failed-run alert + // (with the exact reason) still renders once /state refetches; raw detail stays in server logs. + onError: () => toast.error(t("workspace.unify.taxonomy_start_failed")), } ); }; @@ -172,7 +181,8 @@ export const TopicsSubtopicsContainer = ({ await renameMutation.mutateAsync({ nodeId, label }); toast.success(t("workspace.unify.taxonomy_rename_success")); } catch (error) { - toast.error(getV3ApiErrorMessage(error, t("workspace.unify.taxonomy_rename_failed"))); + // Localized message only — never surface the raw upstream detail (see the trigger toast above). + toast.error(t("workspace.unify.taxonomy_rename_failed")); throw error; } }; @@ -203,8 +213,7 @@ export const TopicsSubtopicsContainer = ({ }); } }, - onError: (error) => - toast.error(getV3ApiErrorMessage(error, t("workspace.unify.taxonomy_remove_failed"))), + onError: () => toast.error(t("workspace.unify.taxonomy_remove_failed")), } ); }; @@ -249,7 +258,7 @@ export const TopicsSubtopicsContainer = ({ isLoadingFields={fieldsQuery.isLoading} hasActiveTree={hasActiveTree} canGenerate={canGenerate} - isGenerating={triggerMutation.isPending} + isGenerating={isRunning} onGenerate={handleGenerate} canWrite={canWrite} /> @@ -258,11 +267,16 @@ export const TopicsSubtopicsContainer = ({ )} - {fieldsUnavailable && ( - - - {fieldsQuery.data?.unavailableMessage ?? t("workspace.unify.taxonomy_fields_unavailable")} - + {serviceUnavailable && ( + + {t("workspace.unify.taxonomy_service_unavailable")} + { + void fieldsQuery.refetch(); + void stateQuery.refetch(); + }}> + {t("common.retry")} + )} diff --git a/apps/web/modules/ee/unify-feedback/topics-subtopics/hooks/use-trigger-taxonomy-run.ts b/apps/web/modules/ee/unify-feedback/topics-subtopics/hooks/use-trigger-taxonomy-run.ts index fddb2e6bdebb..68e739257c35 100644 --- a/apps/web/modules/ee/unify-feedback/topics-subtopics/hooks/use-trigger-taxonomy-run.ts +++ b/apps/web/modules/ee/unify-feedback/topics-subtopics/hooks/use-trigger-taxonomy-run.ts @@ -5,8 +5,9 @@ import { InvalidInputError } from "@formbricks/types/errors"; import { triggerTaxonomyRun } from "../lib/api-client"; import { type TTaxonomyScopeSelection, taxonomyKeys } from "../lib/query"; -/** Start (or resume) a taxonomy run for the selected scope, then invalidate `state` so the new run - * surfaces and run-status polling picks it up. */ +/** Start (or resume) a taxonomy run for the selected scope, then refetch `state` whether the run + * started or failed to start: on success the new pending run surfaces (and polling picks it up); on + * failure the persisted failed run surfaces so its error alert renders instead of only a toast. */ export const useTriggerTaxonomyRun = ({ workspaceId, scope, @@ -19,6 +20,6 @@ export const useTriggerTaxonomyRun = ({ } return triggerTaxonomyRun({ workspaceId, ...scope, fieldLabel: variables.fieldLabel }); }, - onSuccess: () => queryClient.invalidateQueries({ queryKey: taxonomyKeys.state(workspaceId, scope) }), + onSettled: () => queryClient.invalidateQueries({ queryKey: taxonomyKeys.state(workspaceId, scope) }), }); }; diff --git a/apps/web/modules/survey/link/components/verify-email.tsx b/apps/web/modules/survey/link/components/verify-email.tsx index 8a29d77b8f11..8b0b466e6840 100644 --- a/apps/web/modules/survey/link/components/verify-email.tsx +++ b/apps/web/modules/survey/link/components/verify-email.tsx @@ -176,7 +176,11 @@ export const VerifyEmail = ({ placeholder="engineering@acme.com" className="h-10 bg-white" /> - diff --git a/docs/docs.json b/docs/docs.json index 7d0d2d958ba9..c1b18a67d976 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -230,7 +230,7 @@ { "group": "Unify Feedback Features", "pages": [ - "unify-feedback/feedback-directories", + "unify-feedback/feedback-datasets", "unify-feedback/feedback-sources", "unify-feedback/feedback-records", "unify-feedback/dashboards-charts", @@ -457,6 +457,10 @@ ] }, "redirects": [ + { + "destination": "/docs/unify-feedback/feedback-datasets", + "source": "/docs/unify-feedback/feedback-directories" + }, { "destination": "/docs/platform/introduction", "source": "/docs/overview/introduction" diff --git a/docs/self-hosting/advanced/enterprise-features/dashboards.mdx b/docs/self-hosting/advanced/enterprise-features/dashboards.mdx index cd04bb335bca..c05434baac7c 100644 --- a/docs/self-hosting/advanced/enterprise-features/dashboards.mdx +++ b/docs/self-hosting/advanced/enterprise-features/dashboards.mdx @@ -5,6 +5,6 @@ icon: "chart-line" sidebarTitle: "Dashboards & Charts" --- -Build Area, Bar, Line, Pie, and Big Number charts on top of any Feedback Directory, then arrange them on dashboards to share with your team. +Build Area, Bar, Line, Pie, and Big Number charts on top of any Feedback Dataset, then arrange them on dashboards to share with your team. Read the full guide: [Dashboards & Charts](/unify-feedback/dashboards-charts). diff --git a/docs/self-hosting/advanced/enterprise-features/unify-feedback.mdx b/docs/self-hosting/advanced/enterprise-features/unify-feedback.mdx index a7cf490bff98..8bc33a286237 100644 --- a/docs/self-hosting/advanced/enterprise-features/unify-feedback.mdx +++ b/docs/self-hosting/advanced/enterprise-features/unify-feedback.mdx @@ -5,6 +5,6 @@ icon: "layer-group" sidebarTitle: "Unify Feedback" --- -Unify Feedback brings survey responses, CSV uploads, and API-ingested records into the same normalized model under organization-scoped Feedback Directories. Workspaces can be granted access to specific directories. +Unify Feedback brings survey responses, CSV uploads, and API-ingested records into the same normalized model under organization-scoped Feedback Datasets. Workspaces can be granted access to specific datasets. Read the full guide: [Unify Feedback overview](/unify-feedback/overview). diff --git a/docs/self-hosting/advanced/license.mdx b/docs/self-hosting/advanced/license.mdx index 44cbc5d2c80d..8e945711a1c1 100644 --- a/docs/self-hosting/advanced/license.mdx +++ b/docs/self-hosting/advanced/license.mdx @@ -88,7 +88,7 @@ The Enterprise Edition allows us to fund the development of Formbricks sustainab | Contact management & segments | ❌ | ✅ | | Quota Management | ❌ | ✅ | | Unify Feedback Inbox | ❌ | ✅ | -| Feedback Directories | ❌ | ✅ | +| Feedback Datasets | ❌ | ✅ | | Insights Dashboards | ❌ | ✅ | | Audit Logs | ❌ | ✅ | | OIDC SSO (AzureAD, Google, OpenID) | ❌ | ✅ | diff --git a/docs/unify-feedback/dashboards-charts.mdx b/docs/unify-feedback/dashboards-charts.mdx index 14e9aa01f8fb..8dda3e8c290f 100644 --- a/docs/unify-feedback/dashboards-charts.mdx +++ b/docs/unify-feedback/dashboards-charts.mdx @@ -4,7 +4,7 @@ description: "Visualize Feedback Records and group charts onto shareable dashboa icon: "chart-line" --- -Dashboards & Charts let you turn Feedback Records into visual analytics. A **Chart** is a single visualization scoped to one Feedback Directory. A **Dashboard** is a grid of charts you can share with your team. +Dashboards & Charts let you turn Feedback Records into visual analytics. A **Chart** is a single visualization scoped to one Feedback Dataset. A **Dashboard** is a grid of charts you can share with your team. ## Charts @@ -24,7 +24,7 @@ You can build a chart in two ways: ### Manual builder -Pick a Feedback Directory, choose dimensions and measures (count of records, average NPS, ...), apply filters, and select a chart type. Live preview updates as you tweak. +Pick a Feedback Dataset, choose dimensions and measures (count of records, average NPS, ...), apply filters, and select a chart type. Live preview updates as you tweak. ### AI builder @@ -51,5 +51,5 @@ From a dashboard you can: ## Requirements -- A Feedback Directory with records. -- Workspace access to that directory. +- A Feedback Dataset with records. +- Workspace access to that dataset. diff --git a/docs/unify-feedback/feedback-datasets.mdx b/docs/unify-feedback/feedback-datasets.mdx new file mode 100644 index 000000000000..037119ef8373 --- /dev/null +++ b/docs/unify-feedback/feedback-datasets.mdx @@ -0,0 +1,31 @@ +--- +title: "Feedback Datasets" +description: "Org-level containers that group related Feedback Records and their sources." +icon: "folder-tree" +--- + +A **Feedback Dataset** is the top-level container for feedback inside an organization. Every Feedback Record belongs to exactly one dataset, and every source writes into a single dataset. + +## When to create a new dataset + +Create one dataset per logically separate group of feedback. Common patterns: + +- **By product line** (e.g. "Mobile devices", "Web apps") +- **By stakeholder group** (e.g. "Customers", "Employees") +- **By region** (e.g. "Europe", "North America") + +## Workspace access + +Datasets live at the **organization** level but are exposed to **workspaces** through an access list. Each workspace can **only access one dataset.** + +Manage dataset access from **Settings → Organization → Feedback Datasets**: + +- Create new datasets +- Rename or archive datasets +- Add or remove workspace access + +Only **Owners** and **Managers** can manage datasets. Workspace members see the datasets their workspace has access to inside the Unify section. + +## Archiving + +Archiving a dataset hides it from default views but does not delete its records. Use it for one-off programs that have ended. diff --git a/docs/unify-feedback/feedback-directories.mdx b/docs/unify-feedback/feedback-directories.mdx deleted file mode 100644 index 9063c0988d8a..000000000000 --- a/docs/unify-feedback/feedback-directories.mdx +++ /dev/null @@ -1,31 +0,0 @@ ---- -title: "Feedback Directories" -description: "Org-level containers that group related Feedback Records and their sources." -icon: "folder-tree" ---- - -A **Feedback Directory** is the top-level container for feedback inside an organization. Every Feedback Record belongs to exactly one directory, and every source writes into a single directory. - -## When to create a new directory - -Create one directory per logically separate dataset. Common patterns: - -- **By product line** (e.g. "Mobile devices", "Web apps") -- **By stakeholder group** (e.g. "Customers", "Employees") -- **By region** (e.g. "Europe", "North America") - -## Workspace access - -Directories live at the **organization** level but are exposed to **workspaces** through an access list. Each workspace can **only access one directory.** - -Manage directory access from **Settings → Organization → Feedback Directories**: - -- Create new directories -- Rename or archive directories -- Add or remove workspace access - -Only **Owners** and **Managers** can manage directories. Workspace members see the directories their workspace has access to inside the Unify section. - -## Archiving - -Archiving a directory hides it from default views but does not delete its records. Use it for one-off programs that have ended. diff --git a/docs/unify-feedback/feedback-records.mdx b/docs/unify-feedback/feedback-records.mdx index c55e34807a18..34108160d01a 100644 --- a/docs/unify-feedback/feedback-records.mdx +++ b/docs/unify-feedback/feedback-records.mdx @@ -1,6 +1,6 @@ --- title: "Feedback Records" -description: "The normalized unit of feedback inside a Feedback Directory." +description: "The normalized unit of feedback inside a Feedback Dataset." icon: "list-check" --- @@ -17,7 +17,7 @@ Every record has the following fields. Required fields must be mapped by every s | `source_type` | string | Yes | The kind of source (e.g. `survey`, `csv`, `review`). | | `field_id` | string | Yes | Stable identifier for the question/field. | | `field_type` | enum | Yes | One of `text`, `categorical`, `nps`, `csat`, `ces`, `rating`, `number`, `boolean`, `date`. | -| `tenant_id` | string | No | Feedback Directory ID. Set automatically when ingesting. | +| `tenant_id` | string | No | Feedback Dataset ID. Set automatically when ingesting. | | `source_id` | string | No | Reference to the survey/form/ticket/review ID. | | `source_name` | string | No | Human-readable source name for display. | | `field_label` | string | No | The question text or field label. | @@ -35,11 +35,11 @@ The right `value_*` field is set based on `field_type`. For example a `nps` fiel ## Viewing and managing records -Inside a workspace, navigate to **Unify → Feedback Records**. You'll see the latest records across every directory the workspace has access to, sorted by `collected_at`. +Inside a workspace, navigate to **Unify → Feedback Records**. You'll see the latest records across every dataset the workspace has access to, sorted by `collected_at`. From the table you can: -- **Filter** by directory, source, field type, or date range. +- **Filter** by dataset, source, field type, or date range. - **Open** a record drawer to see the full field set and metadata. - **Edit** values inline for cleanup (e.g. relabel a categorical answer). - **Delete** a record. diff --git a/docs/unify-feedback/feedback-sources.mdx b/docs/unify-feedback/feedback-sources.mdx index 8f5b9b595763..6c3a9d5c80cb 100644 --- a/docs/unify-feedback/feedback-sources.mdx +++ b/docs/unify-feedback/feedback-sources.mdx @@ -1,10 +1,10 @@ --- title: "Feedback Sources" -description: "Sources that bring feedback data into a Feedback Directory." +description: "Sources that bring feedback data into a Feedback Dataset." icon: "plug" --- -A **Source** defines how external data is mapped into Feedback Records inside a Feedback Directory. Manage them from **Unify → Sources**. +A **Source** defines how external data is mapped into Feedback Records inside a Feedback Dataset. Manage them from **Unify → Sources**. ## Source types @@ -12,7 +12,7 @@ Formbricks supports three source types: ### 1. Formbricks Surveys -Pipe responses from a Formbricks survey directly into a Feedback Directory. Pick a survey, select the questions you want to ingest - that's it. Formbricks automatically maps each question to its `field_type`. Optionally create Feedback Records of existing responses on connect. +Pipe responses from a Formbricks survey directly into a Feedback Dataset. Pick a survey, select the questions you want to ingest - that's it. Formbricks automatically maps each question to its `field_type`. Optionally create Feedback Records of existing responses on connect. ### 2. CSV Import @@ -26,7 +26,7 @@ A sample CSV is available from the source creation dialog. ### 3. API Ingestion -Push records into a directory programmatically from your own systems. Best for server-to-server ingestion. API reference docs are coming soon. +Push records into a dataset programmatically from your own systems. Best for server-to-server ingestion. API reference docs are coming soon. ## Field mapping @@ -51,4 +51,4 @@ From the Sources page you can: - **Create** a new source for any source type. - **Edit** the mapping for an existing source. - **Pause** or **resume** ingestion. -- **Delete** a source. Existing records stay in the directory. +- **Delete** a source. Existing records stay in the dataset. diff --git a/docs/unify-feedback/journey-management/close-the-loop.mdx b/docs/unify-feedback/journey-management/close-the-loop.mdx index 11a966ad04a0..931f4402d7b7 100644 --- a/docs/unify-feedback/journey-management/close-the-loop.mdx +++ b/docs/unify-feedback/journey-management/close-the-loop.mdx @@ -21,4 +21,4 @@ A signal changes a journey only when it leads to action. Closed-loop tracking re * [Webhooks](/platform/features/integrations/webhooks) to push signals into your warehouse, journey tool, or a custom service * [Native integrations](/platform/features/integrations/overview) to route into Slack, a CRM, or an issue tracker with no code * [Feedback Records](/unify-feedback/feedback-records) to log the action taken and the recovery outcome -* [Feedback Directories](/unify-feedback/feedback-directories) per journey so the journey owner has one place to review +* [Feedback Datasets](/unify-feedback/feedback-datasets) per journey so the journey owner has one place to review diff --git a/docs/unify-feedback/journey-management/detection-signals.mdx b/docs/unify-feedback/journey-management/detection-signals.mdx index e3e2638a5873..f16329f15358 100644 --- a/docs/unify-feedback/journey-management/detection-signals.mdx +++ b/docs/unify-feedback/journey-management/detection-signals.mdx @@ -21,7 +21,7 @@ Journey Activation runs on two loops. The inner loop opens a case to recover one * [Actions](/surveys/website-app-surveys/actions) to trigger a survey on the exact behavior that defines the moment * [Advanced targeting](/surveys/website-app-surveys/advanced-targeting) to fire for the right segment * [Recontact options](/surveys/website-app-surveys/recontact) to cap contact frequency, set once at the workspace level -* [Feedback Directories](/unify-feedback/feedback-directories) and [Sources](/unify-feedback/feedback-sources) to land the signal next to the journey's other data +* [Feedback Datasets](/unify-feedback/feedback-datasets) and [Sources](/unify-feedback/feedback-sources) to land the signal next to the journey's other data For a custom moment, fire the action from your code, then attach a survey to it: diff --git a/docs/unify-feedback/journey-management/map-feedback-to-journeys.mdx b/docs/unify-feedback/journey-management/map-feedback-to-journeys.mdx index a71ff370a86c..fcce668c7c03 100644 --- a/docs/unify-feedback/journey-management/map-feedback-to-journeys.mdx +++ b/docs/unify-feedback/journey-management/map-feedback-to-journeys.mdx @@ -18,7 +18,7 @@ Journey Management measures and improves each customer journey end to end, rathe ## Formbricks Approach -* [Feedback Directories](/unify-feedback/feedback-directories) to hold one journey or program +* [Feedback Datasets](/unify-feedback/feedback-datasets) to hold one journey or program * [Feedback Records](/unify-feedback/feedback-records) tagged with journey and stage * [Hidden Fields](/surveys/general-features/hidden-fields) to pass journey and stage into a link survey at collection time diff --git a/docs/unify-feedback/journey-management/recurring-patterns.mdx b/docs/unify-feedback/journey-management/recurring-patterns.mdx index e7161087eec4..533d611b2dc4 100644 --- a/docs/unify-feedback/journey-management/recurring-patterns.mdx +++ b/docs/unify-feedback/journey-management/recurring-patterns.mdx @@ -20,6 +20,6 @@ Inner loop actioning resolves issues one customer at a time. Outer loop actionin * An open follow-up question on every rating to capture the reason behind the score * [Topics & Subtopics](/unify-feedback/topics-subtopics) to cluster open-text [Feedback Records](/unify-feedback/feedback-records) into themes -* [Feedback Directories](/unify-feedback/feedback-directories) to keep clustering scoped to one journey +* [Feedback Datasets](/unify-feedback/feedback-datasets) to keep clustering scoped to one journey Promote a theme that recurs at the same stage to the pattern register with its stage and rough volume, then hand it to the outer loop for root-cause work. diff --git a/docs/unify-feedback/overview.mdx b/docs/unify-feedback/overview.mdx index cfd8c51a9e66..001195b5e4df 100644 --- a/docs/unify-feedback/overview.mdx +++ b/docs/unify-feedback/overview.mdx @@ -8,25 +8,25 @@ Unify Feedback is the part of Formbricks that consolidates feedback from across ## Why Unify Feedback -Most companies collect feedback in many places: surveys, support tickets, app store reviews, NPS tools, sales calls. Each lives in its own silo with its own schema. Unify Feedback normalizes all of these into **Feedback Records** grouped under **Feedback Directories**, so they can be filtered, visualized, and acted on as one dataset. +Most companies collect feedback in many places: surveys, support tickets, app store reviews, NPS tools, sales calls. Each lives in its own silo with its own schema. Unify Feedback normalizes all of these into **Feedback Records** grouped under **Feedback Datasets**, so they can be filtered, visualized, and acted on together. ## How it works -1. **Create a Feedback Directory.** A directory is a tenant-scoped bucket for related feedback (for example, "Product Feedback" or "Support 2026"). +1. **Create a Feedback Dataset.** A dataset is a tenant-scoped bucket for related feedback (for example, "Product Feedback" or "Support 2026"). 2. **Connect Sources.** Pull data from Formbricks surveys, upload CSVs, or push records via the API. 3. **Explore Records.** Browse, filter, edit, and tag individual Feedback Records. 4. **Discover Topics.** Use vector based Topics & Subtopics (Preview) to cluster open-text feedback. 5. **Visualize.** Build Charts and group them on Dashboards to share insights with your team. - + Org-level buckets that group your feedback. - The normalized unit of feedback inside a directory. + The normalized unit of feedback inside a dataset. - Connectors that bring data into a directory. + Connectors that bring data into a dataset. Visualize and share feedback insights. diff --git a/docs/unify-feedback/topics-subtopics.mdx b/docs/unify-feedback/topics-subtopics.mdx index 61f6c0c6ee05..e59938e1bd54 100644 --- a/docs/unify-feedback/topics-subtopics.mdx +++ b/docs/unify-feedback/topics-subtopics.mdx @@ -12,13 +12,13 @@ Open-text feedback ("Why did you give this score?", support tickets, app reviews ## How it works -1. Pick a Feedback Directory under **Unify → Topics & Subtopics**. -2. Formbricks scans `value_text` across the directory and proposes a set of **Topics** (broad categories) and **Subtopics** (specific themes within a topic). +1. Pick a Feedback Dataset under **Unify → Topics & Subtopics**. +2. Formbricks scans `value_text` across the dataset and proposes a set of **Topics** (broad categories) and **Subtopics** (specific themes within a topic). 3. Each record can be assigned to one Topic and one Subtopic. ## What you can do today -- **Browse** the proposed Topic / Subtopic tree for a directory. +- **Browse** the proposed Topic / Subtopic tree for a dataset. - **Inspect** which records cluster under each Topic. ## Roadmap @@ -29,5 +29,5 @@ Open-text feedback ("Why did you give this score?", support tickets, app reviews ## Requirements -- A Feedback Directory with records. +- A Feedback Dataset with records. - Smart functionality (AI) enabled at the organization level. See [AI Features](/platform/features/ai-features). diff --git a/packages/survey-ui/src/components/elements/file-upload.tsx b/packages/survey-ui/src/components/elements/file-upload.tsx index 41b3509aca00..d2d07e7bde22 100644 --- a/packages/survey-ui/src/components/elements/file-upload.tsx +++ b/packages/survey-ui/src/components/elements/file-upload.tsx @@ -190,7 +190,7 @@ function UploadArea({ )}>