mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-25 09:31:37 -05:00
Compare commits
4 Commits
chore/envo
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20dc147682 | ||
|
|
2bb7a6f277 | ||
|
|
deb062dd03 | ||
|
|
474be86d33 |
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import clsx from "clsx";
|
||||
import { TFunction } from "i18next";
|
||||
import {
|
||||
AirplayIcon,
|
||||
ArrowUpFromDotIcon,
|
||||
@@ -54,6 +55,25 @@ export enum OptionsType {
|
||||
QUOTAS = "Quotas",
|
||||
}
|
||||
|
||||
const getOptionsTypeTranslationKey = (type: OptionsType, t: TFunction): string => {
|
||||
switch (type) {
|
||||
case OptionsType.ELEMENTS:
|
||||
return t("common.elements");
|
||||
case OptionsType.TAGS:
|
||||
return t("common.tags");
|
||||
case OptionsType.ATTRIBUTES:
|
||||
return t("common.attributes");
|
||||
case OptionsType.OTHERS:
|
||||
return t("common.other_filters");
|
||||
case OptionsType.META:
|
||||
return t("common.meta");
|
||||
case OptionsType.HIDDEN_FIELDS:
|
||||
return t("common.hidden_fields");
|
||||
case OptionsType.QUOTAS:
|
||||
return t("common.quotas");
|
||||
}
|
||||
};
|
||||
|
||||
export type ElementOption = {
|
||||
label: string;
|
||||
elementType?: TSurveyElementTypeEnum;
|
||||
@@ -218,7 +238,12 @@ export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementCo
|
||||
{options?.map((data) => (
|
||||
<Fragment key={data.header}>
|
||||
{data?.option.length > 0 && (
|
||||
<CommandGroup heading={<p className="text-sm font-medium text-slate-600">{data.header}</p>}>
|
||||
<CommandGroup
|
||||
heading={
|
||||
<p className="text-sm font-medium text-slate-600">
|
||||
{getOptionsTypeTranslationKey(data.header, t)}
|
||||
</p>
|
||||
}>
|
||||
{data?.option?.map((o) => (
|
||||
<CommandItem
|
||||
key={o.id}
|
||||
|
||||
@@ -188,6 +188,7 @@ checksums:
|
||||
common/duplicate_copy_number: 083cfffd294672043dcbcc4c3dfeac6a
|
||||
common/e_commerce: b9584e7d0449a6d1b0c182d7ff14061e
|
||||
common/edit: eee7f39ff90b18852afc1671f21fbaa9
|
||||
common/elements: 8cb054d952b341e5965284860d532bc7
|
||||
common/email: e7f34943a0c2fb849db1839ff6ef5cb5
|
||||
common/ending_card: 16d30d3a36472159da8c2dbd374dfe22
|
||||
common/enter_url: 468c2276d0f2cb971ff5a47a20fa4b97
|
||||
@@ -258,6 +259,7 @@ checksums:
|
||||
common/members_and_teams: bf5c3fadcb9fc23533ec1532b805ac08
|
||||
common/membership: 83c856bbc2af99d8c3d860959d1d2a85
|
||||
common/membership_not_found: 7ac63584af23396aace9992ad919ffd4
|
||||
common/meta: 842eac888f134f3525f8ea613d933687
|
||||
common/metadata: 695d4f7da261ba76e3be4de495491028
|
||||
common/mobile_overlay_app_works_best_on_desktop: 4509f7bfbb4edbd42e534042d6cb7e72
|
||||
common/mobile_overlay_surveys_look_good: d85169e86077738b9837647bf6d1c7d2
|
||||
@@ -299,6 +301,7 @@ checksums:
|
||||
common/organization_id: ef09b71c84a25b5da02a23c77e68a335
|
||||
common/organization_settings: 11528aa89ae9935e55dcb54478058775
|
||||
common/other: 79acaa6cd481262bea4e743a422529d2
|
||||
common/other_filters: 20b09213c131db47eb8b23e72d0c4bea
|
||||
common/others: 39160224ce0e35eb4eb252c997edf4d8
|
||||
common/overlay_color: 4b72073285d13fff93d094aabffe05ac
|
||||
common/overview: 30c54e4dc4ce599b87d94be34a8617f5
|
||||
|
||||
@@ -215,6 +215,7 @@
|
||||
"duplicate_copy_number": "(Kopie {copyNumber})",
|
||||
"e_commerce": "E-Commerce",
|
||||
"edit": "Bearbeiten",
|
||||
"elements": "Elemente",
|
||||
"email": "E-Mail",
|
||||
"ending_card": "Abschluss-Karte",
|
||||
"enter_url": "URL eingeben",
|
||||
@@ -285,6 +286,7 @@
|
||||
"members_and_teams": "Mitglieder & Teams",
|
||||
"membership": "Mitgliedschaft",
|
||||
"membership_not_found": "Mitgliedschaft nicht gefunden",
|
||||
"meta": "Meta",
|
||||
"metadata": "Metadaten",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks funktioniert am besten auf einem größeren Bildschirm. Um Umfragen zu verwalten oder zu erstellen, wechsle zu einem anderen Gerät.",
|
||||
"mobile_overlay_surveys_look_good": "Keine Sorge – deine Umfragen sehen auf jedem Gerät und jeder Bildschirmgröße großartig aus!",
|
||||
@@ -326,6 +328,7 @@
|
||||
"organization_id": "Organisations-ID",
|
||||
"organization_settings": "Organisationseinstellungen",
|
||||
"other": "Andere",
|
||||
"other_filters": "Weitere Filter",
|
||||
"others": "Andere",
|
||||
"overlay_color": "Overlay-Farbe",
|
||||
"overview": "Überblick",
|
||||
|
||||
@@ -215,6 +215,7 @@
|
||||
"duplicate_copy_number": "(copy {copyNumber})",
|
||||
"e_commerce": "E-Commerce",
|
||||
"edit": "Edit",
|
||||
"elements": "Elements",
|
||||
"email": "Email",
|
||||
"ending_card": "Ending card",
|
||||
"enter_url": "Enter URL",
|
||||
@@ -285,6 +286,7 @@
|
||||
"members_and_teams": "Members & Teams",
|
||||
"membership": "Membership",
|
||||
"membership_not_found": "Membership not found",
|
||||
"meta": "Meta",
|
||||
"metadata": "Metadata",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks works best on a bigger screen. To manage or build surveys, switch to another device.",
|
||||
"mobile_overlay_surveys_look_good": "Do not worry – your surveys look great on every device and screen size!",
|
||||
@@ -326,6 +328,7 @@
|
||||
"organization_id": "Organization ID",
|
||||
"organization_settings": "Organization settings",
|
||||
"other": "Other",
|
||||
"other_filters": "Other Filters",
|
||||
"others": "Others",
|
||||
"overlay_color": "Overlay color",
|
||||
"overview": "Overview",
|
||||
|
||||
@@ -215,6 +215,7 @@
|
||||
"duplicate_copy_number": "(copia {copyNumber})",
|
||||
"e_commerce": "Comercio electrónico",
|
||||
"edit": "Editar",
|
||||
"elements": "Elementos",
|
||||
"email": "Email",
|
||||
"ending_card": "Tarjeta final",
|
||||
"enter_url": "Introducir URL",
|
||||
@@ -285,6 +286,7 @@
|
||||
"members_and_teams": "Miembros y equipos",
|
||||
"membership": "Membresía",
|
||||
"membership_not_found": "Membresía no encontrada",
|
||||
"meta": "Meta",
|
||||
"metadata": "Metadatos",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona mejor en una pantalla más grande. Para gestionar o crear encuestas, cambia a otro dispositivo.",
|
||||
"mobile_overlay_surveys_look_good": "No te preocupes – ¡tus encuestas se ven geniales en todos los dispositivos y tamaños de pantalla!",
|
||||
@@ -326,6 +328,7 @@
|
||||
"organization_id": "ID de organización",
|
||||
"organization_settings": "Ajustes de la organización",
|
||||
"other": "Otro",
|
||||
"other_filters": "Otros Filtros",
|
||||
"others": "Otros",
|
||||
"overlay_color": "Color de superposición",
|
||||
"overview": "Resumen",
|
||||
|
||||
@@ -215,6 +215,7 @@
|
||||
"duplicate_copy_number": "(copie {copyNumber})",
|
||||
"e_commerce": "E-commerce",
|
||||
"edit": "Modifier",
|
||||
"elements": "Éléments",
|
||||
"email": "Email",
|
||||
"ending_card": "Carte de fin",
|
||||
"enter_url": "Saisir l'URL",
|
||||
@@ -285,6 +286,7 @@
|
||||
"members_and_teams": "Membres & Équipes",
|
||||
"membership": "Adhésion",
|
||||
"membership_not_found": "Abonnement non trouvé",
|
||||
"meta": "Méta",
|
||||
"metadata": "Métadonnées",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks fonctionne mieux sur un écran plus grand. Pour gérer ou créer des sondages, passez à un autre appareil.",
|
||||
"mobile_overlay_surveys_look_good": "Ne t'inquiète pas – tes enquêtes sont superbes sur tous les appareils et tailles d'écran!",
|
||||
@@ -326,6 +328,7 @@
|
||||
"organization_id": "Identifiant de l'organisation",
|
||||
"organization_settings": "Paramètres de l'organisation",
|
||||
"other": "Autre",
|
||||
"other_filters": "Autres filtres",
|
||||
"others": "Autres",
|
||||
"overlay_color": "Couleur de superposition",
|
||||
"overview": "Aperçu",
|
||||
|
||||
@@ -215,6 +215,7 @@
|
||||
"duplicate_copy_number": "({copyNumber}. másolat)",
|
||||
"e_commerce": "E-kereskedelem",
|
||||
"edit": "Szerkesztés",
|
||||
"elements": "Elemek",
|
||||
"email": "E-mail",
|
||||
"ending_card": "Befejező kártya",
|
||||
"enter_url": "URL megadása",
|
||||
@@ -285,6 +286,7 @@
|
||||
"members_and_teams": "Tagok és csapatok",
|
||||
"membership": "Tagság",
|
||||
"membership_not_found": "A tagság nem található",
|
||||
"meta": "Meta",
|
||||
"metadata": "Metaadatok",
|
||||
"mobile_overlay_app_works_best_on_desktop": "A Formbricks nagyobb képernyőn működik a legjobban. A kérdőívek kezeléséhez vagy összeállításához váltson másik eszközre.",
|
||||
"mobile_overlay_surveys_look_good": "Ne aggódjon – a kérdőívei minden eszközön és képernyőméretnél remekül néznek ki!",
|
||||
@@ -326,6 +328,7 @@
|
||||
"organization_id": "Szervezetazonosító",
|
||||
"organization_settings": "Szervezet beállításai",
|
||||
"other": "Egyéb",
|
||||
"other_filters": "Egyéb szűrők",
|
||||
"others": "Mások",
|
||||
"overlay_color": "Rávetítés színe",
|
||||
"overview": "Áttekintés",
|
||||
|
||||
@@ -215,6 +215,7 @@
|
||||
"duplicate_copy_number": "(コピー {copyNumber})",
|
||||
"e_commerce": "Eコマース",
|
||||
"edit": "編集",
|
||||
"elements": "要素",
|
||||
"email": "メールアドレス",
|
||||
"ending_card": "終了カード",
|
||||
"enter_url": "URLを入力",
|
||||
@@ -285,6 +286,7 @@
|
||||
"members_and_teams": "メンバー&チーム",
|
||||
"membership": "メンバーシップ",
|
||||
"membership_not_found": "メンバーシップが見つかりません",
|
||||
"meta": "メタ",
|
||||
"metadata": "メタデータ",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks は より 大きな 画面 で最適に 作動します。 フォーム を 管理または 構築する には、 別の デバイス に 切り替える 必要が あります。",
|
||||
"mobile_overlay_surveys_look_good": "ご安心ください - お使い の デバイス や 画面 サイズ に 関係なく、 フォーム は 素晴らしく 見えます!",
|
||||
@@ -326,6 +328,7 @@
|
||||
"organization_id": "組織ID",
|
||||
"organization_settings": "組織設定",
|
||||
"other": "その他",
|
||||
"other_filters": "その他のフィルター",
|
||||
"others": "その他",
|
||||
"overlay_color": "オーバーレイの色",
|
||||
"overview": "概要",
|
||||
|
||||
@@ -215,6 +215,7 @@
|
||||
"duplicate_copy_number": "(kopie {copyNumber})",
|
||||
"e_commerce": "E-commerce",
|
||||
"edit": "Bewerking",
|
||||
"elements": "Elementen",
|
||||
"email": "E-mail",
|
||||
"ending_card": "Einde kaart",
|
||||
"enter_url": "URL invoeren",
|
||||
@@ -285,6 +286,7 @@
|
||||
"members_and_teams": "Leden & teams",
|
||||
"membership": "Lidmaatschap",
|
||||
"membership_not_found": "Lidmaatschap niet gevonden",
|
||||
"meta": "Meta",
|
||||
"metadata": "Metagegevens",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks werkt het beste op een groter scherm. Schakel over naar een ander apparaat om enquêtes te beheren of samen te stellen.",
|
||||
"mobile_overlay_surveys_look_good": "Maakt u zich geen zorgen: uw enquêtes zien er geweldig uit op elk apparaat en schermformaat!",
|
||||
@@ -326,6 +328,7 @@
|
||||
"organization_id": "Organisatie-ID",
|
||||
"organization_settings": "Organisatie-instellingen",
|
||||
"other": "Ander",
|
||||
"other_filters": "Overige filters",
|
||||
"others": "Anderen",
|
||||
"overlay_color": "Overlaykleur",
|
||||
"overview": "Overzicht",
|
||||
|
||||
@@ -215,6 +215,7 @@
|
||||
"duplicate_copy_number": "(cópia {copyNumber})",
|
||||
"e_commerce": "comércio eletrônico",
|
||||
"edit": "Editar",
|
||||
"elements": "Elementos",
|
||||
"email": "Email",
|
||||
"ending_card": "Cartão de encerramento",
|
||||
"enter_url": "Inserir URL",
|
||||
@@ -285,6 +286,7 @@
|
||||
"members_and_teams": "Membros e equipes",
|
||||
"membership": "Associação",
|
||||
"membership_not_found": "Assinatura não encontrada",
|
||||
"meta": "Meta",
|
||||
"metadata": "metadados",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona melhor em uma tela maior. Para gerenciar ou criar pesquisas, mude para outro dispositivo.",
|
||||
"mobile_overlay_surveys_look_good": "Não se preocupe – suas pesquisas ficam ótimas em qualquer dispositivo e tamanho de tela!",
|
||||
@@ -326,6 +328,7 @@
|
||||
"organization_id": "ID da Organização",
|
||||
"organization_settings": "Configurações da Organização",
|
||||
"other": "outro",
|
||||
"other_filters": "Outros Filtros",
|
||||
"others": "Outros",
|
||||
"overlay_color": "Cor da sobreposição",
|
||||
"overview": "Visão Geral",
|
||||
|
||||
@@ -215,6 +215,7 @@
|
||||
"duplicate_copy_number": "(cópia {copyNumber})",
|
||||
"e_commerce": "Comércio Eletrónico",
|
||||
"edit": "Editar",
|
||||
"elements": "Elementos",
|
||||
"email": "Email",
|
||||
"ending_card": "Cartão de encerramento",
|
||||
"enter_url": "Introduzir URL",
|
||||
@@ -285,6 +286,7 @@
|
||||
"members_and_teams": "Membros e equipas",
|
||||
"membership": "Subscrição",
|
||||
"membership_not_found": "Associação não encontrada",
|
||||
"meta": "Meta",
|
||||
"metadata": "Metadados",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona melhor num ecrã maior. Para gerir ou criar inquéritos, mude de dispositivo.",
|
||||
"mobile_overlay_surveys_look_good": "Não se preocupe – os seus inquéritos têm uma ótima aparência em todos os dispositivos e tamanhos de ecrã!",
|
||||
@@ -326,6 +328,7 @@
|
||||
"organization_id": "ID da Organização",
|
||||
"organization_settings": "Configurações da Organização",
|
||||
"other": "Outro",
|
||||
"other_filters": "Outros Filtros",
|
||||
"others": "Outros",
|
||||
"overlay_color": "Cor da sobreposição",
|
||||
"overview": "Visão geral",
|
||||
|
||||
@@ -215,6 +215,7 @@
|
||||
"duplicate_copy_number": "(copie {copyNumber})",
|
||||
"e_commerce": "Comerț electronic",
|
||||
"edit": "Editare",
|
||||
"elements": "Elemente",
|
||||
"email": "Email",
|
||||
"ending_card": "Cardul de finalizare",
|
||||
"enter_url": "Introduceți URL-ul",
|
||||
@@ -285,6 +286,7 @@
|
||||
"members_and_teams": "Membri și echipe",
|
||||
"membership": "Abonament",
|
||||
"membership_not_found": "Apartenența nu a fost găsită",
|
||||
"meta": "Meta",
|
||||
"metadata": "Metadate",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks funcționează cel mai bine pe un ecran mai mare. Pentru a gestiona sau crea chestionare, treceți la un alt dispozitiv.",
|
||||
"mobile_overlay_surveys_look_good": "Nu vă faceți griji – chestionarele dumneavoastră arată grozav pe orice dispozitiv și dimensiune a ecranului!",
|
||||
@@ -326,6 +328,7 @@
|
||||
"organization_id": "ID Organizație",
|
||||
"organization_settings": "Setări Organizație",
|
||||
"other": "Altele",
|
||||
"other_filters": "Alte Filtre",
|
||||
"others": "Altele",
|
||||
"overlay_color": "Culoare overlay",
|
||||
"overview": "Prezentare generală",
|
||||
|
||||
@@ -215,6 +215,7 @@
|
||||
"duplicate_copy_number": "(копия {copyNumber})",
|
||||
"e_commerce": "E-Commerce",
|
||||
"edit": "Редактировать",
|
||||
"elements": "Элементы",
|
||||
"email": "Email",
|
||||
"ending_card": "Завершающая карточка",
|
||||
"enter_url": "Введите URL",
|
||||
@@ -285,6 +286,7 @@
|
||||
"members_and_teams": "Участники и команды",
|
||||
"membership": "Членство",
|
||||
"membership_not_found": "Участие не найдено",
|
||||
"meta": "Мета",
|
||||
"metadata": "Метаданные",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks лучше всего работает на большом экране. Для управления или создания опросов перейдите на другое устройство.",
|
||||
"mobile_overlay_surveys_look_good": "Не волнуйтесь — ваши опросы отлично выглядят на любом устройстве и экране!",
|
||||
@@ -326,6 +328,7 @@
|
||||
"organization_id": "ID организации",
|
||||
"organization_settings": "Настройки организации",
|
||||
"other": "Другое",
|
||||
"other_filters": "Другие фильтры",
|
||||
"others": "Другие",
|
||||
"overlay_color": "Цвет наложения",
|
||||
"overview": "Обзор",
|
||||
|
||||
@@ -215,6 +215,7 @@
|
||||
"duplicate_copy_number": "(kopia {copyNumber})",
|
||||
"e_commerce": "E-handel",
|
||||
"edit": "Redigera",
|
||||
"elements": "Element",
|
||||
"email": "E-post",
|
||||
"ending_card": "Avslutningskort",
|
||||
"enter_url": "Ange URL",
|
||||
@@ -285,6 +286,7 @@
|
||||
"members_and_teams": "Medlemmar och team",
|
||||
"membership": "Medlemskap",
|
||||
"membership_not_found": "Medlemskap hittades inte",
|
||||
"meta": "Meta",
|
||||
"metadata": "Metadata",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks fungerar bäst på en större skärm. Byt till en annan enhet för att hantera eller bygga enkäter.",
|
||||
"mobile_overlay_surveys_look_good": "Oroa dig inte – dina enkäter ser bra ut på alla enheter och skärmstorlekar!",
|
||||
@@ -326,6 +328,7 @@
|
||||
"organization_id": "Organisations-ID",
|
||||
"organization_settings": "Organisationsinställningar",
|
||||
"other": "Annat",
|
||||
"other_filters": "Andra filter",
|
||||
"others": "Andra",
|
||||
"overlay_color": "Overlay-färg",
|
||||
"overview": "Översikt",
|
||||
|
||||
@@ -215,6 +215,7 @@
|
||||
"duplicate_copy_number": "(副本 {copyNumber})",
|
||||
"e_commerce": "电子商务",
|
||||
"edit": "编辑",
|
||||
"elements": "元素",
|
||||
"email": "邮箱",
|
||||
"ending_card": "结尾卡片",
|
||||
"enter_url": "输入 URL",
|
||||
@@ -285,6 +286,7 @@
|
||||
"members_and_teams": "成员和团队",
|
||||
"membership": "会员资格",
|
||||
"membership_not_found": "未找到会员资格",
|
||||
"meta": "元数据",
|
||||
"metadata": "元数据",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks 在 更大 的 屏幕 上 效果 最佳。 若 需要 管理 或 构建 调查, 请 切换 到 其他 设备。",
|
||||
"mobile_overlay_surveys_look_good": "别 担心 – 您 的 调查 在 每 一 种 设备 和 屏幕 尺寸 上 看起来 都 很 棒!",
|
||||
@@ -326,6 +328,7 @@
|
||||
"organization_id": "组织 ID",
|
||||
"organization_settings": "组织 设置",
|
||||
"other": "其他",
|
||||
"other_filters": "其他筛选条件",
|
||||
"others": "其他",
|
||||
"overlay_color": "覆盖层颜色",
|
||||
"overview": "概览",
|
||||
|
||||
@@ -215,6 +215,7 @@
|
||||
"duplicate_copy_number": "(複製 {copyNumber})",
|
||||
"e_commerce": "電子商務",
|
||||
"edit": "編輯",
|
||||
"elements": "元素",
|
||||
"email": "電子郵件",
|
||||
"ending_card": "結尾卡片",
|
||||
"enter_url": "輸入 URL",
|
||||
@@ -285,6 +286,7 @@
|
||||
"members_and_teams": "成員與團隊",
|
||||
"membership": "會員資格",
|
||||
"membership_not_found": "找不到成員資格",
|
||||
"meta": "Meta",
|
||||
"metadata": "元數據",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks 適合在大螢幕上使用。若要管理或建立問卷,請切換到其他裝置。",
|
||||
"mobile_overlay_surveys_look_good": "別擔心 -你的 問卷 在每個 裝置 和 螢幕尺寸 上 都 很出色!",
|
||||
@@ -326,6 +328,7 @@
|
||||
"organization_id": "組織 ID",
|
||||
"organization_settings": "組織設定",
|
||||
"other": "其他",
|
||||
"other_filters": "其他篩選條件",
|
||||
"others": "其他",
|
||||
"overlay_color": "覆蓋層顏色",
|
||||
"overview": "概覽",
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
# Envoy Rate-Limit POC Route Inventory
|
||||
|
||||
This document maps the current Redis-backed rate-limit surface to the Envoy Gateway staging POC for `formbricks/internal#1483`.
|
||||
|
||||
## Gateway-managed in the POC
|
||||
|
||||
### IP-keyed public traffic
|
||||
|
||||
- `auth.login`
|
||||
- App config: `rateLimitConfigs.auth.login`
|
||||
- App behavior: `10 / 15 minutes`
|
||||
- Gateway POC: `POST /api/auth/callback/credentials`
|
||||
- Gateway note: approximated as `40 / hour` because Envoy Gateway global rate limits only support whole-unit windows.
|
||||
|
||||
- `auth.verifyEmail`
|
||||
- App config: `rateLimitConfigs.auth.verifyEmail`
|
||||
- App behavior: `10 / hour`
|
||||
- Gateway POC: `POST /api/auth/callback/token`
|
||||
|
||||
- `api.client`
|
||||
- App config: `rateLimitConfigs.api.client`
|
||||
- App behavior: `100 / minute`
|
||||
- Gateway POC:
|
||||
- `^/api/v1/client/[^/]+/(environment|responses(?:/[^/]+)?|displays|user)$`
|
||||
- `^/api/v2/client/[^/]+/responses(?:/[^/]+)?$`
|
||||
- `^/api/v2/client/[^/]+/displays$`
|
||||
|
||||
- `storage.upload`
|
||||
- App config: `rateLimitConfigs.storage.upload`
|
||||
- App behavior: `5 / minute`
|
||||
- Gateway POC:
|
||||
- `POST ^/api/v1/client/[^/]+/storage$`
|
||||
- `POST ^/api/v2/client/[^/]+/storage$`
|
||||
|
||||
### Header-keyed API traffic
|
||||
|
||||
- `api.v1`
|
||||
- App config: `rateLimitConfigs.api.v1`
|
||||
- App behavior: `100 / minute`
|
||||
- Gateway POC:
|
||||
- `^/api/v1/management/` when `x-api-key` is present
|
||||
- `^/api/v1/webhooks/` when `x-api-key` is present
|
||||
|
||||
- `storage.upload`
|
||||
- App config: `rateLimitConfigs.storage.upload`
|
||||
- App behavior: `5 / minute`
|
||||
- Gateway POC:
|
||||
- `POST /api/v1/management/storage` when `x-api-key` is present
|
||||
|
||||
- `storage.delete`
|
||||
- App config: `rateLimitConfigs.storage.delete`
|
||||
- App behavior: `5 / minute`
|
||||
- Gateway POC:
|
||||
- `DELETE ^/storage/[^/]+/(public|private)/.+$` when `x-api-key` is present
|
||||
|
||||
## Left in the app on purpose
|
||||
|
||||
- `rateLimitConfigs.auth.signup`
|
||||
- `rateLimitConfigs.auth.forgotPassword`
|
||||
- profile email update actions
|
||||
- follow-up dispatch
|
||||
- link survey email sending
|
||||
- license recheck
|
||||
- user/session/org keyed authenticated flows
|
||||
- all runtime logic in:
|
||||
- `apps/web/app/lib/api/with-api-logging.ts`
|
||||
- `apps/web/modules/auth/lib/authOptions.ts`
|
||||
- `apps/web/modules/core/rate-limit/rate-limit-configs.ts`
|
||||
|
||||
## Negative controls
|
||||
|
||||
- `/api/v1/client/og` must stay unthrottled at the gateway layer.
|
||||
- `/api/v2/health` stays outside the gateway path for the staging POC.
|
||||
- `OPTIONS` stays unthrottled because Envoy policy rules only match the explicitly listed methods.
|
||||
|
||||
## How to interpret failures
|
||||
|
||||
- Gateway `429`
|
||||
- look for `x-envoy-ratelimited`
|
||||
- body will not use the Formbricks `code: "too_many_requests"` JSON shape
|
||||
|
||||
- App `429`
|
||||
- V1 responses use `apps/web/app/lib/api/response.ts`
|
||||
- V2 responses use `apps/web/modules/api/v2/lib/response.ts`
|
||||
- V3 responses use `apps/web/app/api/v3/lib/response.ts`
|
||||
@@ -1313,11 +1313,26 @@ export const reconcileCloudStripeSubscriptionsForOrganization = async (
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
hobbySubscriptions.map(({ subscription }) =>
|
||||
client.subscriptions.cancel(subscription.id, {
|
||||
prorate: false,
|
||||
})
|
||||
)
|
||||
hobbySubscriptions.map(async ({ subscription }) => {
|
||||
try {
|
||||
await client.subscriptions.cancel(subscription.id, {
|
||||
prorate: false,
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Stripe.errors.StripeInvalidRequestError &&
|
||||
err.statusCode === 404 &&
|
||||
err.code === "resource_missing"
|
||||
) {
|
||||
logger.warn(
|
||||
{ subscriptionId: subscription.id, organizationId },
|
||||
"Subscription already deleted, skipping cancel"
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ function Consent({
|
||||
/>
|
||||
|
||||
{/* Consent Checkbox */}
|
||||
<div className="relative">
|
||||
<div className="relative" data-element-input>
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
|
||||
<label
|
||||
|
||||
@@ -83,7 +83,7 @@ function CTA({
|
||||
/>
|
||||
|
||||
{/* CTA Button */}
|
||||
<div className="relative space-y-2">
|
||||
<div className="relative space-y-2" data-element-input>
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
|
||||
{buttonExternal ? (
|
||||
@@ -95,7 +95,7 @@ function CTA({
|
||||
disabled={disabled}
|
||||
className="text-button font-button-weight flex items-center gap-2"
|
||||
variant={buttonVariant}
|
||||
size={"custom"}>
|
||||
size="custom">
|
||||
{buttonLabel}
|
||||
<SquareArrowOutUpRightIcon className="size-4" />
|
||||
</Button>
|
||||
|
||||
@@ -161,7 +161,7 @@ function DateElement({
|
||||
videoUrl={videoUrl}
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
<div className="relative" data-element-input>
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
{/* Calendar - Always visible */}
|
||||
<div className="w-full">
|
||||
|
||||
@@ -292,7 +292,7 @@ function FileUpload({
|
||||
imageAltText={imageAltText}
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
<div className="relative" data-element-input>
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
|
||||
<div
|
||||
|
||||
@@ -112,7 +112,7 @@ function FormField({
|
||||
/>
|
||||
|
||||
{/* Form Fields */}
|
||||
<div className="relative">
|
||||
<div className="relative" data-element-input>
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
<div className="space-y-3">
|
||||
{visibleFields.map((field) => {
|
||||
|
||||
@@ -94,7 +94,7 @@ function Matrix({
|
||||
/>
|
||||
|
||||
{/* Matrix Table */}
|
||||
<div className="relative">
|
||||
<div className="relative" data-element-input>
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
|
||||
{/* Table container with overflow for mobile */}
|
||||
|
||||
@@ -145,7 +145,7 @@ function DropdownVariant({
|
||||
searchPlaceholder,
|
||||
searchNoResultsText,
|
||||
}: Readonly<DropdownVariantProps>): React.JSX.Element {
|
||||
const handleOptionToggle = (optionId: string) => {
|
||||
const handleOptionToggle = (optionId: string): void => {
|
||||
if (selectedValues.includes(optionId)) {
|
||||
handleOptionRemove(optionId);
|
||||
} else {
|
||||
@@ -540,7 +540,7 @@ function MultiSelect({
|
||||
/>
|
||||
|
||||
{/* Options */}
|
||||
<div className="relative">
|
||||
<div className="relative" data-element-input>
|
||||
{variant === "dropdown" ? (
|
||||
<DropdownVariant
|
||||
inputId={inputId}
|
||||
|
||||
@@ -172,7 +172,7 @@ function NPS({
|
||||
/>
|
||||
|
||||
{/* NPS Options */}
|
||||
<div className="relative">
|
||||
<div className="relative" data-element-input>
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
<fieldset className="w-full px-[2px]" dir={dir}>
|
||||
<legend className="sr-only">NPS rating options</legend>
|
||||
|
||||
@@ -79,7 +79,7 @@ function OpenText({
|
||||
imageUrl={imageUrl}
|
||||
videoUrl={videoUrl}
|
||||
/>
|
||||
<div className="relative">
|
||||
<div className="relative" data-element-input>
|
||||
<ElementError errorMessage={errorMessage} />
|
||||
{/* Input or Textarea */}
|
||||
<div className="space-y-1">
|
||||
|
||||
@@ -106,7 +106,7 @@ function PictureSelect({
|
||||
/>
|
||||
|
||||
{/* Picture Grid - 2 columns */}
|
||||
<div className="relative">
|
||||
<div className="relative" data-element-input>
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
{allowMulti ? (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
|
||||
@@ -223,7 +223,7 @@ function Ranking({
|
||||
/>
|
||||
|
||||
{/* Ranking Options */}
|
||||
<div className="relative">
|
||||
<div className="relative" data-element-input>
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
<fieldset className="w-full" dir={dir}>
|
||||
<legend className="sr-only">Ranking options</legend>
|
||||
|
||||
@@ -407,7 +407,7 @@ function Rating({
|
||||
/>
|
||||
|
||||
{/* Rating Options */}
|
||||
<div className="relative">
|
||||
<div className="relative" data-element-input>
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
<fieldset className="w-full" dir={dir}>
|
||||
<legend className="sr-only">Rating options</legend>
|
||||
|
||||
@@ -181,7 +181,7 @@ function SingleSelect({
|
||||
/>
|
||||
|
||||
{/* Options */}
|
||||
<div>
|
||||
<div data-element-input>
|
||||
{variant === "dropdown" ? (
|
||||
<>
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
@@ -278,7 +278,7 @@ function SingleSelect({
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<div className="relative" data-element-input>
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
<RadioGroup
|
||||
name={inputId}
|
||||
|
||||
@@ -278,11 +278,12 @@ export function BlockConditional({
|
||||
if (hasValidationErrors) {
|
||||
setElementErrors(errorMap);
|
||||
|
||||
// Find the first element with an error and scroll to it
|
||||
// Find the first element with an error and scroll to its input area (not the headline)
|
||||
const firstErrorElementId = Object.keys(errorMap)[0];
|
||||
const form = elementFormRefs.current.get(firstErrorElementId);
|
||||
if (form) {
|
||||
form.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
const scrollTarget = form.querySelector("[data-element-input]") ?? form;
|
||||
scrollTarget.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -290,7 +291,8 @@ export function BlockConditional({
|
||||
// Also run legacy validation for elements not yet migrated to centralized validation
|
||||
const firstInvalidForm = findFirstInvalidForm();
|
||||
if (firstInvalidForm) {
|
||||
firstInvalidForm.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
const scrollTarget = firstInvalidForm.querySelector("[data-element-input]") ?? firstInvalidForm;
|
||||
scrollTarget.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type z } from "zod";
|
||||
import type { TI18nString } from "../i18n";
|
||||
import type { TSurveyLanguage } from "./types";
|
||||
import { getTextContent } from "./validation";
|
||||
import { findLanguageCodesForDuplicateLabels, getTextContent } from "./validation";
|
||||
|
||||
const extractLanguageCodes = (surveyLanguages?: TSurveyLanguage[]): string[] => {
|
||||
if (!surveyLanguages) return [];
|
||||
@@ -92,28 +92,5 @@ export const validateElementLabels = (
|
||||
return null;
|
||||
};
|
||||
|
||||
export const findLanguageCodesForDuplicateLabels = (
|
||||
labels: TI18nString[],
|
||||
surveyLanguages: TSurveyLanguage[]
|
||||
): string[] => {
|
||||
const enabledLanguages = surveyLanguages.filter((lang) => lang.enabled);
|
||||
const languageCodes = extractLanguageCodes(enabledLanguages);
|
||||
|
||||
const languagesToCheck = languageCodes.length === 0 ? ["default"] : languageCodes;
|
||||
|
||||
const duplicateLabels = new Set<string>();
|
||||
|
||||
for (const language of languagesToCheck) {
|
||||
const labelTexts = labels
|
||||
.map((label) => label[language])
|
||||
.filter((text): text is string => typeof text === "string" && text.trim().length > 0)
|
||||
.map((text) => text.trim());
|
||||
const uniqueLabels = new Set(labelTexts);
|
||||
|
||||
if (uniqueLabels.size !== labelTexts.length) {
|
||||
duplicateLabels.add(language);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(duplicateLabels);
|
||||
};
|
||||
// Re-export for backwards compatibility
|
||||
export { findLanguageCodesForDuplicateLabels };
|
||||
|
||||
@@ -228,7 +228,10 @@ export const findLanguageCodesForDuplicateLabels = (
|
||||
const duplicateLabels = new Set<string>();
|
||||
|
||||
for (const language of languagesToCheck) {
|
||||
const labelTexts = labels.map((label) => label[language].trim()).filter(Boolean);
|
||||
const labelTexts = labels
|
||||
.map((label) => label[language])
|
||||
.filter((text): text is string => typeof text === "string" && text.trim().length > 0)
|
||||
.map((text) => text.trim());
|
||||
const uniqueLabels = new Set(labelTexts);
|
||||
|
||||
if (uniqueLabels.size !== labelTexts.length) {
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
# Rate-Limit Burst Checks
|
||||
|
||||
These scripts are for validating the Envoy Gateway staging POC without changing runtime behavior in the app.
|
||||
|
||||
## What the script reports
|
||||
|
||||
For each request it prints:
|
||||
|
||||
- request number
|
||||
- scenario name
|
||||
- HTTP status
|
||||
- response source guess
|
||||
|
||||
`source=gateway` means the response included `x-envoy-ratelimited`.
|
||||
|
||||
`source=app` means the response body matched the Formbricks `too_many_requests` JSON shape.
|
||||
|
||||
`source=unknown` means the response was neither of those and should be inspected manually.
|
||||
|
||||
## Required environment variables
|
||||
|
||||
- `HOST`
|
||||
- defaults to `https://staging.app.formbricks.com`
|
||||
- `ENVIRONMENT_ID`
|
||||
- required for client API scenarios
|
||||
- `API_KEY`
|
||||
- required for management, webhooks, and storage-delete scenarios
|
||||
|
||||
## Optional environment variables
|
||||
|
||||
- `COUNT`
|
||||
- number of requests to send
|
||||
- `SLEEP_SECONDS`
|
||||
- delay between requests
|
||||
- `RESPONSE_ID`
|
||||
- used by the `v2-responses-put` scenario
|
||||
- `WEBHOOK_ID`
|
||||
- used by the `webhooks-api-key` scenario
|
||||
- `FILE_KEY`
|
||||
- used by the `storage-delete-api-key` scenario
|
||||
|
||||
## Example
|
||||
|
||||
```bash
|
||||
HOST=https://staging.app.formbricks.com \
|
||||
ENVIRONMENT_ID=<environment_id> \
|
||||
COUNT=110 \
|
||||
scripts/rate-limit/burst-test.sh v1-client-environment
|
||||
```
|
||||
@@ -1,174 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCENARIO="${1:-}"
|
||||
HOST="${HOST:-https://staging.app.formbricks.com}"
|
||||
ENVIRONMENT_ID="${ENVIRONMENT_ID:-}"
|
||||
API_KEY="${API_KEY:-}"
|
||||
COUNT="${COUNT:-20}"
|
||||
SLEEP_SECONDS="${SLEEP_SECONDS:-0}"
|
||||
RESPONSE_ID="${RESPONSE_ID:-envoy-poc-response}"
|
||||
WEBHOOK_ID="${WEBHOOK_ID:-envoy-poc-webhook}"
|
||||
FILE_KEY="${FILE_KEY:-envoy-poc-file.txt}"
|
||||
|
||||
if [[ -z "$SCENARIO" ]]; then
|
||||
echo "usage: scripts/rate-limit/burst-test.sh <scenario>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
require_env_id() {
|
||||
if [[ -z "$ENVIRONMENT_ID" ]]; then
|
||||
echo "ENVIRONMENT_ID is required for scenario '$SCENARIO'" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
require_api_key() {
|
||||
if [[ -z "$API_KEY" ]]; then
|
||||
echo "API_KEY is required for scenario '$SCENARIO'" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
METHOD="GET"
|
||||
URL=""
|
||||
BODY=""
|
||||
CONTENT_TYPE=""
|
||||
EXTRA_HEADERS=()
|
||||
|
||||
case "$SCENARIO" in
|
||||
login)
|
||||
METHOD="POST"
|
||||
URL="$HOST/api/auth/callback/credentials"
|
||||
BODY="email=rate-limit%40example.com&password=wrong-password"
|
||||
CONTENT_TYPE="application/x-www-form-urlencoded"
|
||||
;;
|
||||
verify-token)
|
||||
METHOD="POST"
|
||||
URL="$HOST/api/auth/callback/token"
|
||||
BODY="token=invalid-token"
|
||||
CONTENT_TYPE="application/x-www-form-urlencoded"
|
||||
;;
|
||||
v1-client-environment)
|
||||
require_env_id
|
||||
URL="$HOST/api/v1/client/$ENVIRONMENT_ID/environment"
|
||||
;;
|
||||
v1-client-storage)
|
||||
require_env_id
|
||||
METHOD="POST"
|
||||
URL="$HOST/api/v1/client/$ENVIRONMENT_ID/storage"
|
||||
BODY='{}'
|
||||
CONTENT_TYPE="application/json"
|
||||
;;
|
||||
v2-responses-post)
|
||||
require_env_id
|
||||
METHOD="POST"
|
||||
URL="$HOST/api/v2/client/$ENVIRONMENT_ID/responses"
|
||||
BODY='{}'
|
||||
CONTENT_TYPE="application/json"
|
||||
;;
|
||||
v2-responses-put)
|
||||
require_env_id
|
||||
METHOD="PUT"
|
||||
URL="$HOST/api/v2/client/$ENVIRONMENT_ID/responses/$RESPONSE_ID"
|
||||
BODY='{}'
|
||||
CONTENT_TYPE="application/json"
|
||||
;;
|
||||
v2-displays-post)
|
||||
require_env_id
|
||||
METHOD="POST"
|
||||
URL="$HOST/api/v2/client/$ENVIRONMENT_ID/displays"
|
||||
BODY='{}'
|
||||
CONTENT_TYPE="application/json"
|
||||
;;
|
||||
v2-client-storage)
|
||||
require_env_id
|
||||
METHOD="POST"
|
||||
URL="$HOST/api/v2/client/$ENVIRONMENT_ID/storage"
|
||||
BODY='{}'
|
||||
CONTENT_TYPE="application/json"
|
||||
;;
|
||||
management-api-key)
|
||||
require_api_key
|
||||
URL="$HOST/api/v1/management/me"
|
||||
EXTRA_HEADERS+=("x-api-key: $API_KEY")
|
||||
;;
|
||||
management-storage-api-key)
|
||||
require_api_key
|
||||
METHOD="POST"
|
||||
URL="$HOST/api/v1/management/storage"
|
||||
BODY='{}'
|
||||
CONTENT_TYPE="application/json"
|
||||
EXTRA_HEADERS+=("x-api-key: $API_KEY")
|
||||
;;
|
||||
webhooks-api-key)
|
||||
require_api_key
|
||||
URL="$HOST/api/v1/webhooks/$WEBHOOK_ID"
|
||||
EXTRA_HEADERS+=("x-api-key: $API_KEY")
|
||||
;;
|
||||
storage-delete-api-key)
|
||||
require_env_id
|
||||
require_api_key
|
||||
METHOD="DELETE"
|
||||
URL="$HOST/storage/$ENVIRONMENT_ID/public/$FILE_KEY"
|
||||
EXTRA_HEADERS+=("x-api-key: $API_KEY")
|
||||
;;
|
||||
*)
|
||||
echo "unknown scenario: $SCENARIO" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||
|
||||
for i in $(seq 1 "$COUNT"); do
|
||||
header_file="$TMP_DIR/$i.headers"
|
||||
body_file="$TMP_DIR/$i.body"
|
||||
|
||||
curl_args=(
|
||||
-sS
|
||||
-D "$header_file"
|
||||
-o "$body_file"
|
||||
-X "$METHOD"
|
||||
)
|
||||
|
||||
if [[ -n "$CONTENT_TYPE" ]]; then
|
||||
curl_args+=(-H "content-type: $CONTENT_TYPE")
|
||||
fi
|
||||
|
||||
for header in "${EXTRA_HEADERS[@]}"; do
|
||||
curl_args+=(-H "$header")
|
||||
done
|
||||
|
||||
if [[ -n "$BODY" ]]; then
|
||||
curl_args+=(--data "$BODY")
|
||||
fi
|
||||
|
||||
status_code="$(curl "${curl_args[@]}" -w '%{http_code}' "$URL")"
|
||||
|
||||
source="unknown"
|
||||
if rg -qi '^x-envoy-ratelimited:' "$header_file"; then
|
||||
source="gateway"
|
||||
elif rg -q '"code":"too_many_requests"' "$body_file"; then
|
||||
source="app"
|
||||
fi
|
||||
|
||||
printf '%03d scenario=%s status=%s source=%s\n' "$i" "$SCENARIO" "$status_code" "$source"
|
||||
|
||||
if [[ "$status_code" == "429" ]]; then
|
||||
header_summary="$(
|
||||
{
|
||||
tr -d '\r' < "$header_file" |
|
||||
rg -i '^(x-envoy-ratelimited|content-type|retry-after):' |
|
||||
paste -sd '; ' -
|
||||
} || true
|
||||
)"
|
||||
printf ' headers: %s\n' "${header_summary:-<none>}"
|
||||
fi
|
||||
|
||||
if [[ "$SLEEP_SECONDS" != "0" ]]; then
|
||||
sleep "$SLEEP_SECONDS"
|
||||
fi
|
||||
done
|
||||
Reference in New Issue
Block a user