Compare commits

..

3 Commits

Author SHA1 Message Date
Cursor Agent 208d83eb08 feat: add lazy response detail modal on OpenTextSummary row click
Co-authored-by: johannes <johannes@formbricks.com>
2026-05-15 11:43:06 +00:00
Johannes 0a7482da0f Cursor: Apply local changes for cloud agent 2026-05-15 13:38:24 +02:00
Bhagya Amarasinghe c286a3330a fix: rate limit storage uploads per environment (#8006) 2026-05-15 10:55:17 +00:00
34 changed files with 545 additions and 185 deletions
@@ -106,20 +106,18 @@ export const ResponseCardModal = ({
</DialogDescription>
</VisuallyHidden>
<DialogBody>
<div className="my-3">
<SingleResponseCard
survey={survey}
response={responses[currentIndex]}
user={user}
environment={environment}
environmentTags={environmentTags}
isReadOnly={isReadOnly}
updateResponse={updateResponse}
updateResponseList={updateResponseList}
setSelectedResponseId={setSelectedResponseId}
locale={locale}
/>
</div>
<SingleResponseCard
survey={survey}
response={responses[currentIndex]}
user={user}
environment={environment}
environmentTags={environmentTags}
isReadOnly={isReadOnly}
updateResponse={updateResponse}
updateResponseList={updateResponseList}
setSelectedResponseId={setSelectedResponseId}
locale={locale}
/>
</DialogBody>
<DialogFooter>
<Button
@@ -3,6 +3,7 @@
import Link from "next/link";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey, TSurveyElementSummaryOpenText } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time";
@@ -14,20 +15,28 @@ import { EmptyState } from "@/modules/ui/components/empty-state";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
import { ElementSummaryHeader } from "./ElementSummaryHeader";
import { ResponseSampleModal } from "./ResponseSampleModal";
interface OpenTextSummaryProps {
elementSummary: TSurveyElementSummaryOpenText;
environmentId: string;
environment: TEnvironment;
survey: TSurvey;
locale: TUserLocale;
isReadOnly: boolean;
}
export const OpenTextSummary = ({ elementSummary, environmentId, survey, locale }: OpenTextSummaryProps) => {
export const OpenTextSummary = ({
elementSummary,
environment,
survey,
locale,
isReadOnly,
}: OpenTextSummaryProps) => {
const { t } = useTranslation();
const [visibleResponses, setVisibleResponses] = useState(10);
const [selectedResponseId, setSelectedResponseId] = useState<string | null>(null);
const handleLoadMore = () => {
// Increase the number of visible responses by 10, not exceeding the total number of responses
setVisibleResponses((prevVisibleResponses) =>
Math.min(prevVisibleResponses + 10, elementSummary.samples.length)
);
@@ -54,12 +63,16 @@ export const OpenTextSummary = ({ elementSummary, environmentId, survey, locale
</TableHeader>
<TableBody>
{elementSummary.samples.slice(0, visibleResponses).map((response) => (
<TableRow key={response.id}>
<TableRow
key={response.id}
className="cursor-pointer hover:bg-slate-50"
onClick={() => setSelectedResponseId(response.id)}>
<TableCell className="w-1/4">
{response.contact ? (
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
href={`/environments/${environment.id}/contacts/${response.contact.id}`}
onClick={(e) => e.stopPropagation()}>
<div className="hidden md:flex">
<PersonAvatar personId={response.contact.id} />
</div>
@@ -84,7 +97,7 @@ export const OpenTextSummary = ({ elementSummary, environmentId, survey, locale
<TableCell className="w-1/6">
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
</TableCell>
<TableCell className="w-1/6">
<TableCell className="w-1/6" onClick={(e) => e.stopPropagation()}>
<IdBadge id={response.id} />
</TableCell>
</TableRow>
@@ -100,6 +113,15 @@ export const OpenTextSummary = ({ elementSummary, environmentId, survey, locale
)}
</div>
)}
<ResponseSampleModal
responseId={selectedResponseId}
onClose={() => setSelectedResponseId(null)}
survey={survey}
environment={environment}
isReadOnly={isReadOnly}
locale={locale}
/>
</div>
);
};
@@ -0,0 +1,113 @@
"use client";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import { useEffect, useRef, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TResponseWithQuotas } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags";
import { TUserLocale } from "@formbricks/types/user";
import {
getResponseAction,
getTagsByEnvironmentIdAction,
} from "@/modules/analysis/components/SingleResponseCard/actions";
import { SingleResponseCard } from "@/modules/analysis/components/SingleResponseCard";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
interface ResponseSampleModalProps {
responseId: string | null;
onClose: () => void;
survey: TSurvey;
environment: TEnvironment;
isReadOnly: boolean;
locale: TUserLocale;
}
export const ResponseSampleModal = ({
responseId,
onClose,
survey,
environment,
isReadOnly,
locale,
}: ResponseSampleModalProps) => {
const [response, setResponse] = useState<TResponseWithQuotas | null>(null);
const [tags, setTags] = useState<TTag[]>([]);
const [isLoading, setIsLoading] = useState(false);
// Cache fetched data per response ID to avoid re-fetching on re-open
const cache = useRef<Map<string, { response: TResponseWithQuotas; tags: TTag[] }>>(new Map());
useEffect(() => {
if (!responseId) return;
const cached = cache.current.get(responseId);
if (cached) {
setResponse(cached.response);
setTags(cached.tags);
return;
}
setIsLoading(true);
setResponse(null);
Promise.all([
getResponseAction({ responseId }),
getTagsByEnvironmentIdAction({ environmentId: environment.id }),
])
.then(([responseResult, tagsResult]) => {
const fetchedResponse = responseResult?.data ?? null;
const fetchedTags = tagsResult?.data ?? [];
if (fetchedResponse) {
const entry = { response: fetchedResponse as TResponseWithQuotas, tags: fetchedTags };
cache.current.set(responseId, entry);
setResponse(entry.response);
setTags(entry.tags);
}
})
.finally(() => {
setIsLoading(false);
});
}, [responseId, environment.id]);
const handleOpenChange = (open: boolean) => {
if (!open) onClose();
};
return (
<Dialog open={!!responseId} onOpenChange={handleOpenChange}>
<DialogContent width="wide">
<VisuallyHidden asChild>
<DialogTitle>Survey Response Details</DialogTitle>
</VisuallyHidden>
<VisuallyHidden asChild>
<DialogDescription>Full response details</DialogDescription>
</VisuallyHidden>
<DialogBody>
{isLoading || !response ? (
<div className="py-12">
<LoadingSpinner />
</div>
) : (
<SingleResponseCard
survey={survey}
response={response}
environment={environment}
environmentTags={tags}
isReadOnly={isReadOnly}
locale={locale}
/>
)}
</DialogBody>
</DialogContent>
</Dialog>
);
};
@@ -41,9 +41,17 @@ interface SummaryListProps {
environment: TEnvironment;
survey: TSurvey;
locale: TUserLocale;
isReadOnly: boolean;
}
export const SummaryList = ({ summary, environment, responseCount, survey, locale }: SummaryListProps) => {
export const SummaryList = ({
summary,
environment,
responseCount,
survey,
locale,
isReadOnly,
}: SummaryListProps) => {
const { setSelectedFilter, selectedFilter } = useResponseFilter();
const { t } = useTranslation();
const setFilter = (
@@ -113,9 +121,10 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
<OpenTextSummary
key={elementSummary.element.id}
elementSummary={elementSummary}
environmentId={environment.id}
environment={environment}
survey={survey}
locale={locale}
isReadOnly={isReadOnly}
/>
);
}
@@ -51,6 +51,7 @@ interface SummaryPageProps {
locale: TUserLocale;
initialSurveySummary?: TSurveySummary;
isQuotasAllowed: boolean;
isReadOnly: boolean;
}
export const SummaryPage = ({
@@ -60,6 +61,7 @@ export const SummaryPage = ({
locale,
initialSurveySummary,
isQuotasAllowed,
isReadOnly,
}: SummaryPageProps) => {
const { t } = useTranslation();
const searchParams = useSearchParams();
@@ -230,6 +232,7 @@ export const SummaryPage = ({
survey={surveyMemoized}
environment={environment}
locale={locale}
isReadOnly={isReadOnly}
/>
</>
);
@@ -91,6 +91,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
locale={user.locale ?? DEFAULT_LOCALE}
initialSurveySummary={initialSurveySummary}
isQuotasAllowed={isQuotasAllowed}
isReadOnly={isReadOnly}
/>
<IdBadge id={surveyId} label={t("common.survey_id")} variant="column" />
@@ -6,6 +6,7 @@ import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging
import { MAX_FILE_UPLOAD_SIZES } from "@/lib/constants";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getSurvey } from "@/lib/survey/service";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { getBiggerUploadFileSizePermission } from "@/modules/ee/license-check/lib/utils";
import { getSignedUrlForUpload } from "@/modules/storage/service";
@@ -79,6 +80,17 @@ export const POST = withV1ApiWrapper({
};
}
try {
await applyRateLimit(rateLimitConfigs.storage.uploadPerEnvironment, environmentId);
} catch (error) {
return {
response: responses.tooManyRequestsResponse(
error instanceof Error ? error.message : "Rate limit exceeded",
true
),
};
}
const isBiggerFileUploadAllowed = await getBiggerUploadFileSizePermission(organization.id);
const maxFileUploadSize = isBiggerFileUploadAllowed
? MAX_FILE_UPLOAD_SIZES.big
+2
View File
@@ -640,6 +640,7 @@ checksums:
environments/contacts/attributes_msg_email_or_userid_required: febc8b0cda4dd45d2c3cdb1ac2d45dcb
environments/contacts/attributes_msg_new_attribute_created: 5cba6158c4305c05104814ec1479267c
environments/contacts/attributes_msg_userid_already_exists: 9c695538befc152806c460f52a73821a
environments/contacts/collapse_response: d697c361333407ba0ddef3e5bb277f5d
environments/contacts/contact_deleted_successfully: c5b64a42a50e055f9e27ec49e20e03fa
environments/contacts/create_attribute: 87320615901f95b4f35ee83c290a3a6c
environments/contacts/create_new_attribute: c17d407dacd0b90f360f9f5e899d662f
@@ -659,6 +660,7 @@ checksums:
environments/contacts/edit_attribute_values_description: 21593dfaf4cad965ffc17685bc005509
environments/contacts/edit_attributes: a5c3b540441d34b4c0b7faab8f0f0c89
environments/contacts/edit_attributes_success: 39f93b1a6f1605bc5951f4da5847bb22
environments/contacts/expand_response: f3b8ca2a0cf6b7af31c318b5e0dbbea8
environments/contacts/generate_personal_link: 9ac0865f6876d40fe858f94eae781eb8
environments/contacts/generate_personal_link_description: b9dbaf9e2d8362505b7e3cfa40f415a6
environments/contacts/invalid_csv_column_names: dcb8534e7d4c00b9ea7bdaf389f72328
+2
View File
@@ -676,6 +676,7 @@
"attributes_msg_email_or_userid_required": "Entweder E-Mail oder Benutzer-ID ist erforderlich. Die vorhandenen Werte wurden beibehalten.",
"attributes_msg_new_attribute_created": "Neues Attribut “{key}” mit Typ “{dataType}” erstellt",
"attributes_msg_userid_already_exists": "Die Benutzer-ID existiert bereits für diese Umgebung und wurde nicht aktualisiert.",
"collapse_response": "Antwort einklappen",
"contact_deleted_successfully": "Kontakt erfolgreich gelöscht",
"create_attribute": "Attribut erstellen",
"create_new_attribute": "Neues Attribut erstellen",
@@ -695,6 +696,7 @@
"edit_attribute_values_description": "Ändern Sie die Werte für bestimmte Attribute dieses Kontakts.",
"edit_attributes": "Attribute bearbeiten",
"edit_attributes_success": "Kontaktattribute erfolgreich aktualisiert",
"expand_response": "Antwort ausklappen",
"generate_personal_link": "Persönlichen Link generieren",
"generate_personal_link_description": "Wähle eine veröffentlichte Umfrage aus, um einen personalisierten Link für diesen Kontakt zu generieren.",
"invalid_csv_column_names": "Ungültige CSV-Spaltennamen: {columns}. Spaltennamen, die zu neuen Attributen werden, dürfen nur Kleinbuchstaben, Zahlen und Unterstriche enthalten und müssen mit einem Buchstaben beginnen.",
+2
View File
@@ -676,6 +676,7 @@
"attributes_msg_email_or_userid_required": "Either email or user ID is required. The existing values were preserved.",
"attributes_msg_new_attribute_created": "Created new attribute “{key}” with type “{dataType}”",
"attributes_msg_userid_already_exists": "The user ID already exists for this environment and was not updated.",
"collapse_response": "Collapse response",
"contact_deleted_successfully": "Contact deleted successfully",
"create_attribute": "Create attribute",
"create_new_attribute": "Create new attribute",
@@ -695,6 +696,7 @@
"edit_attribute_values_description": "Change the values for specific attributes for this contact.",
"edit_attributes": "Edit Attributes",
"edit_attributes_success": "Contact attributes updated successfully",
"expand_response": "Expand response",
"generate_personal_link": "Generate Personal Link",
"generate_personal_link_description": "Select a published survey to generate a personalized link for this contact.",
"invalid_csv_column_names": "Invalid CSV column name(s): {columns}. Column names that will become new attributes must only contain lowercase letters, numbers, and underscores, and must start with a letter.",
+2
View File
@@ -676,6 +676,7 @@
"attributes_msg_email_or_userid_required": "Se requiere el correo electrónico o el ID de usuario. Se conservaron los valores existentes.",
"attributes_msg_new_attribute_created": "Se creó el atributo nuevo “{key}” con el tipo “{dataType}”",
"attributes_msg_userid_already_exists": "El ID de usuario ya existe para este entorno y no se actualizó.",
"collapse_response": "Contraer respuesta",
"contact_deleted_successfully": "Contacto eliminado correctamente",
"create_attribute": "Crear atributo",
"create_new_attribute": "Crear atributo nuevo",
@@ -695,6 +696,7 @@
"edit_attribute_values_description": "Cambia los valores de atributos específicos para este contacto.",
"edit_attributes": "Editar atributos",
"edit_attributes_success": "Atributos del contacto actualizados correctamente",
"expand_response": "Expandir respuesta",
"generate_personal_link": "Generar enlace personal",
"generate_personal_link_description": "Selecciona una encuesta publicada para generar un enlace personalizado para este contacto.",
"invalid_csv_column_names": "Nombre(s) de columna CSV no válido(s): {columns}. Los nombres de columna que se convertirán en nuevos atributos solo deben contener letras minúsculas, números y guiones bajos, y deben comenzar con una letra.",
+2
View File
@@ -676,6 +676,7 @@
"attributes_msg_email_or_userid_required": "L'e-mail ou l'identifiant utilisateur est requis. Les valeurs existantes ont été conservées.",
"attributes_msg_new_attribute_created": "Nouvel attribut “{key}” créé avec le type “{dataType}”",
"attributes_msg_userid_already_exists": "L'identifiant utilisateur existe déjà pour cet environnement et n'a pas été mis à jour.",
"collapse_response": "Réduire la réponse",
"contact_deleted_successfully": "Contact supprimé avec succès",
"create_attribute": "Créer un attribut",
"create_new_attribute": "Créer un nouvel attribut",
@@ -695,6 +696,7 @@
"edit_attribute_values_description": "Modifiez les valeurs d'attributs spécifiques pour ce contact.",
"edit_attributes": "Modifier les attributs",
"edit_attributes_success": "Attributs du contact mis à jour avec succès",
"expand_response": "Développer la réponse",
"generate_personal_link": "Générer un lien personnel",
"generate_personal_link_description": "Sélectionnez une enquête publiée pour générer un lien personnalisé pour ce contact.",
"invalid_csv_column_names": "Nom(s) de colonne CSV invalide(s): {columns}. Les noms de colonnes qui deviendront de nouveaux attributs ne doivent contenir que des lettres minuscules, des chiffres et des underscores, et doivent commencer par une lettre.",
+2
View File
@@ -676,6 +676,7 @@
"attributes_msg_email_or_userid_required": "Vagy e-mail-cím, vagy felhasználó-azonosító szükséges. A meglévő értékek megmaradtak.",
"attributes_msg_new_attribute_created": "Az új „{dataType}” típusú „{key}” attribútum létrehozva",
"attributes_msg_userid_already_exists": "A felhasználó-azonosító már létezik ennél a környezetnél, és nem lett frissítve.",
"collapse_response": "Válasz összecsukása",
"contact_deleted_successfully": "A partner sikeresen törölve",
"create_attribute": "Attribútum létrehozása",
"create_new_attribute": "Új attribútum létrehozása",
@@ -695,6 +696,7 @@
"edit_attribute_values_description": "Bizonyos attribútumok értékének megváltoztatása ennél a partnernél.",
"edit_attributes": "Attribútumok szerkesztése",
"edit_attributes_success": "A partner attribútumai sikeresen frissítve",
"expand_response": "Válasz kibontása",
"generate_personal_link": "Személyes hivatkozás előállítása",
"generate_personal_link_description": "Válasszon egy közzétett kérdőívet, hogy személyre szabott hivatkozást állítson elő ehhez a partnerhez.",
"invalid_csv_column_names": "Érvénytelen CSV-oszlopnevek: {columns}. Az új attribútumokká váló oszlopnevek csak ékezet nélküli kisbetűket, számokat és aláhúzásjeleket tartalmazhatnak, valamint betűvel kell kezdődniük.",
+2
View File
@@ -676,6 +676,7 @@
"attributes_msg_email_or_userid_required": "メールアドレスまたはユーザーIDのいずれかが必要です。既存の値は保持されました。",
"attributes_msg_new_attribute_created": "新しい属性“{key}”を型“{dataType}”で作成しました",
"attributes_msg_userid_already_exists": "この環境にはすでにユーザーIDが存在するため、更新されませんでした。",
"collapse_response": "回答を折りたたむ",
"contact_deleted_successfully": "連絡先を正常に削除しました",
"create_attribute": "属性を作成",
"create_new_attribute": "新しい属性を作成",
@@ -695,6 +696,7 @@
"edit_attribute_values_description": "この連絡先の特定の属性の値を変更します。",
"edit_attributes": "属性を編集",
"edit_attributes_success": "連絡先属性が正常に更新されました",
"expand_response": "回答を展開する",
"generate_personal_link": "個人リンクを生成",
"generate_personal_link_description": "公開されたフォームを選択して、この連絡先用のパーソナライズされたリンクを生成します。",
"invalid_csv_column_names": "無効なCSV列名: {columns}。新しい属性となる列名は、小文字、数字、アンダースコアのみを含み、文字で始まる必要があります。",
+2
View File
@@ -676,6 +676,7 @@
"attributes_msg_email_or_userid_required": "E-mail of gebruikers-ID is vereist. De bestaande waarden zijn behouden.",
"attributes_msg_new_attribute_created": "Nieuw attribuut “{key}” aangemaakt met type “{dataType}”",
"attributes_msg_userid_already_exists": "De gebruikers-ID bestaat al voor deze omgeving en is niet bijgewerkt.",
"collapse_response": "Antwoord inklappen",
"contact_deleted_successfully": "Contact succesvol verwijderd",
"create_attribute": "Attribuut aanmaken",
"create_new_attribute": "Nieuw attribuut aanmaken",
@@ -695,6 +696,7 @@
"edit_attribute_values_description": "Wijzig de waarden voor specifieke attributen voor dit contact.",
"edit_attributes": "Attributen bewerken",
"edit_attributes_success": "Contactattributen succesvol bijgewerkt",
"expand_response": "Antwoord uitklappen",
"generate_personal_link": "Persoonlijke link genereren",
"generate_personal_link_description": "Selecteer een gepubliceerde enquête om een gepersonaliseerde link voor dit contact te genereren.",
"invalid_csv_column_names": "Ongeldige CSV-kolomna(a)m(en): {columns}. Kolomnamen die nieuwe kenmerken worden, mogen alleen kleine letters, cijfers en underscores bevatten en moeten beginnen met een letter.",
+2
View File
@@ -676,6 +676,7 @@
"attributes_msg_email_or_userid_required": "E-mail ou ID de usuário é obrigatório. Os valores existentes foram preservados.",
"attributes_msg_new_attribute_created": "Novo atributo “{key}” criado com tipo “{dataType}”",
"attributes_msg_userid_already_exists": "O ID de usuário já existe para este ambiente e não foi atualizado.",
"collapse_response": "Recolher resposta",
"contact_deleted_successfully": "Contato excluído com sucesso",
"create_attribute": "Criar atributo",
"create_new_attribute": "Criar novo atributo",
@@ -695,6 +696,7 @@
"edit_attribute_values_description": "Altere os valores de atributos específicos para este contato.",
"edit_attributes": "Editar atributos",
"edit_attributes_success": "Atributos do contato atualizados com sucesso",
"expand_response": "Expandir resposta",
"generate_personal_link": "Gerar link pessoal",
"generate_personal_link_description": "Selecione uma pesquisa publicada para gerar um link personalizado para este contato.",
"invalid_csv_column_names": "Nome(s) de coluna CSV inválido(s): {columns}. Os nomes de colunas que se tornarão novos atributos devem conter apenas letras minúsculas, números e sublinhados, e devem começar com uma letra.",
+2
View File
@@ -676,6 +676,7 @@
"attributes_msg_email_or_userid_required": "É necessário um email ou ID de utilizador. Os valores existentes foram preservados.",
"attributes_msg_new_attribute_created": "Criado novo atributo “{key}” com tipo “{dataType}”",
"attributes_msg_userid_already_exists": "O ID de utilizador já existe para este ambiente e não foi atualizado.",
"collapse_response": "Recolher resposta",
"contact_deleted_successfully": "Contacto eliminado com sucesso",
"create_attribute": "Criar atributo",
"create_new_attribute": "Criar novo atributo",
@@ -695,6 +696,7 @@
"edit_attribute_values_description": "Altere os valores de atributos específicos para este contacto.",
"edit_attributes": "Editar atributos",
"edit_attributes_success": "Atributos do contacto atualizados com sucesso",
"expand_response": "Expandir resposta",
"generate_personal_link": "Gerar Link Pessoal",
"generate_personal_link_description": "Selecione um inquérito publicado para gerar um link personalizado para este contacto.",
"invalid_csv_column_names": "Nome(s) de coluna CSV inválido(s): {columns}. Os nomes de colunas que se tornarão novos atributos devem conter apenas letras minúsculas, números e underscores, e devem começar com uma letra.",
+2
View File
@@ -676,6 +676,7 @@
"attributes_msg_email_or_userid_required": "Este necesar fie un email, fie un ID de utilizator. Valorile existente au fost păstrate.",
"attributes_msg_new_attribute_created": "A fost creat un nou atribut „{key}” cu tipul „{dataType}”",
"attributes_msg_userid_already_exists": "ID-ul de utilizator există deja pentru acest mediu și nu a fost actualizat.",
"collapse_response": "Restrânge răspunsul",
"contact_deleted_successfully": "Contact șters cu succes",
"create_attribute": "Creează atribut",
"create_new_attribute": "Creează atribut nou",
@@ -695,6 +696,7 @@
"edit_attribute_values_description": "Modifică valorile anumitor atribute pentru acest contact.",
"edit_attributes": "Editează atributele",
"edit_attributes_success": "Atributele contactului au fost actualizate cu succes",
"expand_response": "Extinde răspunsul",
"generate_personal_link": "Generează link personal",
"generate_personal_link_description": "Selectați un sondaj publicat pentru a genera un link personalizat pentru acest contact.",
"invalid_csv_column_names": "Nume de coloană CSV nevalide: {columns}. Numele coloanelor care vor deveni atribute noi trebuie să conțină doar litere mici, cifre și caractere de subliniere și trebuie să înceapă cu o literă.",
+2
View File
@@ -676,6 +676,7 @@
"attributes_msg_email_or_userid_required": "Требуется либо email, либо user ID. Существующие значения были сохранены.",
"attributes_msg_new_attribute_created": "Создан новый атрибут «{key}» с типом «{dataType}»",
"attributes_msg_userid_already_exists": "Этот user ID уже существует в данной среде и не был обновлён.",
"collapse_response": "Свернуть ответ",
"contact_deleted_successfully": "Контакт успешно удалён",
"create_attribute": "Создать атрибут",
"create_new_attribute": "Создать новый атрибут",
@@ -695,6 +696,7 @@
"edit_attribute_values_description": "Измените значения определённых атрибутов для этого контакта.",
"edit_attributes": "Редактировать атрибуты",
"edit_attributes_success": "Атрибуты контакта успешно обновлены",
"expand_response": "Развернуть ответ",
"generate_personal_link": "Сгенерировать персональную ссылку",
"generate_personal_link_description": "Выберите опубликованный опрос, чтобы сгенерировать персональную ссылку для этого контакта.",
"invalid_csv_column_names": "Недопустимые имена столбцов в CSV: {columns}. Имена столбцов, которые станут новыми атрибутами, должны содержать только строчные буквы, цифры и подчёркивания, а также начинаться с буквы.",
+2
View File
@@ -676,6 +676,7 @@
"attributes_msg_email_or_userid_required": "Antingen e-post eller användar-ID krävs. De befintliga värdena har bevarats.",
"attributes_msg_new_attribute_created": "Nytt attribut ”{key}” med typen ”{dataType}” har skapats",
"attributes_msg_userid_already_exists": "Användar-ID finns redan för denna miljö och uppdaterades inte.",
"collapse_response": "Dölj svar",
"contact_deleted_successfully": "Kontakt borttagen",
"create_attribute": "Skapa attribut",
"create_new_attribute": "Skapa nytt attribut",
@@ -695,6 +696,7 @@
"edit_attribute_values_description": "Ändra värdena för specifika attribut för denna kontakt.",
"edit_attributes": "Redigera attribut",
"edit_attributes_success": "Kontaktens attribut har uppdaterats",
"expand_response": "Visa svar",
"generate_personal_link": "Generera personlig länk",
"generate_personal_link_description": "Välj en publicerad enkät för att generera en personlig länk för denna kontakt.",
"invalid_csv_column_names": "Ogiltiga CSV-kolumnnamn: {columns}. Kolumnnamn som ska bli nya attribut får bara innehålla små bokstäver, siffror och understreck, och måste börja med en bokstav.",
+2
View File
@@ -676,6 +676,7 @@
"attributes_msg_email_or_userid_required": "Email veya kullanıcı kimliği gereklidir. Mevcut değerler korundu.",
"attributes_msg_new_attribute_created": "\"{key}\" adında \"{dataType}\" türünde yeni özellik oluşturuldu",
"attributes_msg_userid_already_exists": "Kullanıcı kimliği bu ortamda zaten mevcut ve güncellenmedi.",
"collapse_response": "Yanıtı daralt",
"contact_deleted_successfully": "Contact başarıyla silindi",
"create_attribute": "Özellik oluştur",
"create_new_attribute": "Yeni özellik oluştur",
@@ -695,6 +696,7 @@
"edit_attribute_values_description": "Bu kişi için belirli özelliklerin değerlerini değiştirin.",
"edit_attributes": "Özellikleri Düzenle",
"edit_attributes_success": "Contact attributes başarıyla güncellendi",
"expand_response": "Yanıtı genişlet",
"generate_personal_link": "Kişisel Bağlantı Oluştur",
"generate_personal_link_description": "Bu kişi için kişiselleştirilmiş bir bağlantı oluşturmak üzere yayınlanmış bir survey seçin.",
"invalid_csv_column_names": "Geçersiz CSV sütun adı/adları: {columns}. Yeni özellik olacak sütun adları yalnızca küçük harfler, rakamlar ve alt çizgi içermeli ve bir harfle başlamalıdır.",
+2
View File
@@ -676,6 +676,7 @@
"attributes_msg_email_or_userid_required": "需要填写邮箱或用户ID。已保留现有值。",
"attributes_msg_new_attribute_created": "已创建新属性“{key}”,类型为“{dataType}”",
"attributes_msg_userid_already_exists": "该环境下的用户ID已存在,未进行更新。",
"collapse_response": "收起回复",
"contact_deleted_successfully": "联系人 删除 成功",
"create_attribute": "创建属性",
"create_new_attribute": "创建新属性",
@@ -695,6 +696,7 @@
"edit_attribute_values_description": "更改此联系人的特定属性值。",
"edit_attributes": "编辑属性",
"edit_attributes_success": "联系人属性更新成功",
"expand_response": "展开回复",
"generate_personal_link": "生成个人链接",
"generate_personal_link_description": "选择一个已发布的调查,为此联系人生成个性化链接。",
"invalid_csv_column_names": "无效的 CSV 列名:{columns}。作为新属性的列名只能包含小写字母、数字和下划线,并且必须以字母开头。",
+2
View File
@@ -676,6 +676,7 @@
"attributes_msg_email_or_userid_required": "必須填寫電子郵件或使用者 ID。已保留現有值。",
"attributes_msg_new_attribute_created": "已建立新屬性「{key}」,型別為「{dataType}」",
"attributes_msg_userid_already_exists": "此環境已存在該使用者 ID,未進行更新。",
"collapse_response": "收合回應",
"contact_deleted_successfully": "聯絡人已成功刪除",
"create_attribute": "建立屬性",
"create_new_attribute": "建立新屬性",
@@ -695,6 +696,7 @@
"edit_attribute_values_description": "變更此聯絡人特定屬性的值。",
"edit_attributes": "編輯屬性",
"edit_attributes_success": "聯絡人屬性已成功更新",
"expand_response": "展開回應",
"generate_personal_link": "產生個人連結",
"generate_personal_link_description": "選擇一個已發佈的問卷,為此聯絡人產生個人化連結。",
"invalid_csv_column_names": "無效的 CSV 欄位名稱:{columns}。作為新屬性的欄位名稱只能包含小寫字母、數字和底線,且必須以字母開頭。",
@@ -4,7 +4,7 @@ import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { deleteResponse, getResponse } from "@/lib/response/service";
import { createTag } from "@/lib/tag/service";
import { createTag, getTagsByEnvironmentId } from "@/lib/tag/service";
import { addTagToRespone, deleteTagOnResponse } from "@/lib/tagOnResponse/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
@@ -175,6 +175,32 @@ export const deleteResponseAction = authenticatedActionClient.inputSchema(ZDelet
})
);
const ZGetTagsByEnvironmentIdAction = z.object({
environmentId: ZId,
});
export const getTagsByEnvironmentIdAction = authenticatedActionClient
.inputSchema(ZGetTagsByEnvironmentIdAction)
.action(async ({ parsedInput, ctx }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "read",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
},
],
});
return await getTagsByEnvironmentId(parsedInput.environmentId);
});
const ZGetResponseAction = z.object({
responseId: ZId,
});
@@ -134,6 +134,7 @@ export const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
return (
<div className="flex items-center justify-between gap-4 border-t border-slate-200 px-6 py-3">
<div className="flex flex-wrap items-center gap-2">
<IdBadge id={responseId} />
<SingleResponseCardMetadata response={response} locale={locale} />
{tagsState?.map((tag) => (
<Tag
@@ -161,7 +162,6 @@ export const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
/>
)}
</div>
<IdBadge id={responseId} />
</div>
);
};
@@ -6,7 +6,6 @@ import { useTranslation } from "react-i18next";
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
import { TResponse } from "@formbricks/types/responses";
import { TUserLocale } from "@formbricks/types/user";
import { Button } from "@/modules/ui/components/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
interface InfoIconButtonProps {
@@ -26,9 +25,11 @@ const InfoIconButton = ({
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="icon" aria-label={ariaLabel}>
<button
className="flex h-4 w-4 items-center justify-center rounded text-slate-500 hover:text-slate-700"
aria-label={ariaLabel}>
<Icon className="h-4 w-4" />
</Button>
</button>
</TooltipTrigger>
<TooltipContent avoidCollisions align="start" side="bottom" className={maxWidth}>
{tooltipContent}
@@ -1,6 +1,6 @@
"use client";
import { ReactNode, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
@@ -16,12 +16,7 @@ import { deleteResponseAction, getResponseAction } from "./actions";
import { ResponseTagsWrapper } from "./components/ResponseTagsWrapper";
import { SingleResponseCardBody } from "./components/SingleResponseCardBody";
import { SingleResponseCardHeader } from "./components/SingleResponseCardHeader";
import { isSubmissionTimeMoreThan5Minutes, isValidValue } from "./util";
export interface SingleResponseCardHeaderRenderProps {
onDeleteClick: () => void;
canResponseBeDeleted: boolean;
}
import { isValidValue } from "./util";
interface SingleResponseCardProps {
survey: TSurvey;
@@ -34,11 +29,6 @@ interface SingleResponseCardProps {
isReadOnly: boolean;
setSelectedResponseId?: (responseId: string | null) => void;
locale: TUserLocale;
/**
* Optional render-prop to replace the default header. Receives helpers to
* trigger the (shared) delete dialog so callers don't have to reimplement it.
*/
renderHeader?: (props: SingleResponseCardHeaderRenderProps) => ReactNode;
}
export const SingleResponseCard = ({
@@ -52,8 +42,7 @@ export const SingleResponseCard = ({
isReadOnly,
setSelectedResponseId,
locale,
renderHeader,
}: Readonly<SingleResponseCardProps>) => {
}: SingleResponseCardProps) => {
const hasQuotas = (response?.quotas && response.quotas.length > 0) ?? false;
const [decrementQuotas, setDecrementQuotas] = useState(hasQuotas);
const { t } = useTranslation();
@@ -139,30 +128,19 @@ export const SingleResponseCard = ({
}
};
const canResponseBeDeleted = response.finished
? true
: isSubmissionTimeMoreThan5Minutes(response.updatedAt);
return (
<div className="group relative">
<div className="relative z-20 rounded-xl border border-slate-200 bg-white shadow-sm transition-all">
{renderHeader ? (
renderHeader({
onDeleteClick: () => setDeleteDialogOpen(true),
canResponseBeDeleted,
})
) : (
<SingleResponseCardHeader
pageType="response"
response={response}
survey={survey}
environment={environment}
user={user}
isReadOnly={isReadOnly}
setDeleteDialogOpen={setDeleteDialogOpen}
locale={locale}
/>
)}
<div className="relative z-20 my-6 rounded-xl border border-slate-200 bg-white shadow-sm transition-all">
<SingleResponseCardHeader
pageType="response"
response={response}
survey={survey}
environment={environment}
user={user}
isReadOnly={isReadOnly}
setDeleteDialogOpen={setDeleteDialogOpen}
locale={locale}
/>
<SingleResponseCardBody
survey={survey}
@@ -59,6 +59,7 @@ describe("rateLimitConfigs", () => {
expect(rateLimitConfigs).toHaveProperty("auth");
expect(rateLimitConfigs).toHaveProperty("api");
expect(rateLimitConfigs).toHaveProperty("actions");
expect(rateLimitConfigs).toHaveProperty("storage");
});
test("should have all auth configurations", () => {
@@ -81,6 +82,11 @@ describe("rateLimitConfigs", () => {
"licenseRecheck",
]);
});
test("should have all storage configurations", () => {
const storageConfigs = Object.keys(rateLimitConfigs.storage);
expect(storageConfigs).toEqual(["upload", "uploadPerEnvironment", "delete"]);
});
});
describe("Zod Validation", () => {
@@ -89,6 +95,7 @@ describe("rateLimitConfigs", () => {
...Object.values(rateLimitConfigs.auth),
...Object.values(rateLimitConfigs.api),
...Object.values(rateLimitConfigs.actions),
...Object.values(rateLimitConfigs.storage),
];
for (const config of allConfigs) {
@@ -105,6 +112,7 @@ describe("rateLimitConfigs", () => {
Object.values(rateLimitConfigs.auth).forEach((config) => allNamespaces.push(config.namespace));
Object.values(rateLimitConfigs.api).forEach((config) => allNamespaces.push(config.namespace));
Object.values(rateLimitConfigs.actions).forEach((config) => allNamespaces.push(config.namespace));
Object.values(rateLimitConfigs.storage).forEach((config) => allNamespaces.push(config.namespace));
const uniqueNamespaces = new Set(allNamespaces);
expect(uniqueNamespaces.size).toBe(allNamespaces.length);
@@ -143,6 +151,7 @@ describe("rateLimitConfigs", () => {
{ config: rateLimitConfigs.actions.emailUpdate, identifier: "user-profile" },
{ config: rateLimitConfigs.actions.accountDeletion, identifier: "user-account-delete" },
{ config: rateLimitConfigs.storage.upload, identifier: "storage-upload" },
{ config: rateLimitConfigs.storage.uploadPerEnvironment, identifier: "storage-upload-env" },
{ config: rateLimitConfigs.storage.delete, identifier: "storage-delete" },
];
@@ -172,6 +181,15 @@ describe("rateLimitConfigs", () => {
expect(config.namespace).toBe("storage:upload");
});
test("should properly configure storage upload per environment rate limit", async () => {
const config = rateLimitConfigs.storage.uploadPerEnvironment;
// Verify configuration values
expect(config.interval).toBe(60); // 1 minute
expect(config.allowedPerInterval).toBe(100); // 100 requests per minute
expect(config.namespace).toBe("storage:upload:environment");
});
test("should properly configure storage delete rate limit", async () => {
const config = rateLimitConfigs.storage.delete;
@@ -35,6 +35,11 @@ export const rateLimitConfigs = {
storage: {
upload: { interval: 60, allowedPerInterval: 5, namespace: "storage:upload" }, // 5 per minute
uploadPerEnvironment: {
interval: 60,
allowedPerInterval: 100,
namespace: "storage:upload:environment",
}, // 100 per minute per environment
delete: { interval: 60, allowedPerInterval: 5, namespace: "storage:delete" }, // 5 per minute
},
} as const;
@@ -1,8 +1,8 @@
"use client";
import { LinkIcon, PencilIcon, RefreshCwIcon, TrashIcon } from "lucide-react";
import { LinkIcon, PencilIcon, TrashIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TContactAttributeDataType, TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
@@ -46,13 +46,6 @@ export const ContactControlBar = ({
const [isDeletingPerson, setIsDeletingPerson] = useState(false);
const [isGenerateLinkModalOpen, setIsGenerateLinkModalOpen] = useState(false);
const [isEditAttributesModalOpen, setIsEditAttributesModalOpen] = useState(false);
const [isRefreshing, startRefreshTransition] = useTransition();
const handleRefresh = () => {
startRefreshTransition(() => {
router.refresh();
});
};
const handleDeletePerson = async () => {
setIsDeletingPerson(true);
@@ -69,22 +62,18 @@ export const ContactControlBar = ({
setDeleteDialogOpen(false);
};
if (isReadOnly) {
return null;
}
const iconActions = [
{
icon: RefreshCwIcon,
tooltip: t("common.refresh"),
onClick: handleRefresh,
isVisible: true,
disabled: isRefreshing,
iconClassName: isRefreshing ? "animate-spin" : undefined,
},
{
icon: PencilIcon,
tooltip: t("environments.contacts.edit_attributes"),
onClick: () => {
setIsEditAttributesModalOpen(true);
},
isVisible: !isReadOnly,
isVisible: true,
},
{
icon: LinkIcon,
@@ -92,7 +81,7 @@ export const ContactControlBar = ({
onClick: () => {
setIsGenerateLinkModalOpen(true);
},
isVisible: !isReadOnly,
isVisible: true,
},
{
icon: TrashIcon,
@@ -100,7 +89,7 @@ export const ContactControlBar = ({
onClick: () => {
setDeleteDialogOpen(true);
},
isVisible: !isReadOnly,
isVisible: true,
},
];
@@ -1,8 +1,9 @@
"use client";
import { MessageSquareTextIcon, TrashIcon } from "lucide-react";
import { ChevronDownIcon, ChevronUpIcon, MessageSquareTextIcon, TrashIcon } from "lucide-react";
import Link from "next/link";
import { useMemo } from "react";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TResponseWithQuotas } from "@formbricks/types/responses";
@@ -10,9 +11,22 @@ import { TSurvey } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags";
import { TUser, TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { SingleResponseCard } from "@/modules/analysis/components/SingleResponseCard";
import {
deleteResponseAction,
getResponseAction,
} from "@/modules/analysis/components/SingleResponseCard/actions";
import { ResponseTagsWrapper } from "@/modules/analysis/components/SingleResponseCard/components/ResponseTagsWrapper";
import { SingleResponseCardBody } from "@/modules/analysis/components/SingleResponseCard/components/SingleResponseCardBody";
import {
isSubmissionTimeMoreThan5Minutes,
isValidValue,
} from "@/modules/analysis/components/SingleResponseCard/util";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { Button } from "@/modules/ui/components/button";
import { DecrementQuotasCheckbox } from "@/modules/ui/components/decrement-quotas-checkbox";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
interface ResponseSurveyCardProps {
@@ -39,72 +53,207 @@ export const ResponseSurveyCard = ({
isReadOnly,
}: Readonly<ResponseSurveyCardProps>) => {
const { t } = useTranslation();
const [isExpanded, setIsExpanded] = useState(true);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const surveyWithReplacedRecall = useMemo(() => replaceHeadlineRecall(survey, "default"), [survey]);
const hasQuotas = (response?.quotas && response.quotas.length > 0) ?? false;
const [decrementQuotas, setDecrementQuotas] = useState(hasQuotas);
const surveyWithReplacedRecall = useMemo(
() => replaceHeadlineRecall(survey, "default"),
[survey]
);
const skippedQuestions: string[][] = useMemo(() => {
const questions = getElementsFromBlocks(surveyWithReplacedRecall.blocks);
const flushTemp = (temp: string[], result: string[][], shouldReverse = false) => {
if (temp.length > 0) {
if (shouldReverse) temp.reverse();
result.push([...temp]);
temp.length = 0;
}
};
const processFinishedResponse = () => {
const result: string[][] = [];
const temp: string[] = [];
for (const question of questions) {
if (isValidValue(response.data[question.id])) {
flushTemp(temp, result);
} else {
temp.push(question.id);
}
}
flushTemp(temp, result);
return result;
};
const processUnfinishedResponse = () => {
const result: string[][] = [];
const temp: string[] = [];
for (let index = questions.length - 1; index >= 0; index--) {
const question = questions[index];
const hasNoData = !response.data[question.id];
const shouldSkip =
hasNoData && (result.length === 0 || !isValidValue(response.data[question.id]));
if (shouldSkip) {
temp.push(question.id);
} else {
flushTemp(temp, result, true);
}
}
flushTemp(temp, result);
return result;
};
return response.finished ? processFinishedResponse() : processUnfinishedResponse();
}, [response.finished, response.data, surveyWithReplacedRecall.blocks]);
const canResponseBeDeleted = response.finished
? true
: isSubmissionTimeMoreThan5Minutes(response.updatedAt);
const handleDeleteResponse = async () => {
setIsDeleting(true);
try {
if (isReadOnly) {
throw new Error(t("common.not_authorized"));
}
const result = await deleteResponseAction({ responseId: response.id, decrementQuotas });
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
}
updateResponseList([response.id]);
toast.success(t("environments.surveys.responses.response_deleted_successfully"));
setDeleteDialogOpen(false);
} catch (error) {
if (error instanceof Error) toast.error(error.message);
} finally {
setIsDeleting(false);
}
};
const updateFetchedResponses = async () => {
const updatedResponse = await getResponseAction({ responseId: response.id });
if (updatedResponse?.data) {
updateResponse(response.id, updatedResponse.data as TResponseWithQuotas);
}
};
const bodyId = `response-card-body-${response.id}`;
const showDeleteButton = !!user && !isReadOnly;
return (
<SingleResponseCard
survey={surveyWithReplacedRecall}
response={response}
user={user}
environment={environment}
environmentTags={environmentTags}
isReadOnly={isReadOnly}
updateResponse={updateResponse}
updateResponseList={updateResponseList}
locale={locale}
renderHeader={({ onDeleteClick, canResponseBeDeleted }) => (
<div className="flex items-center justify-between p-4">
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-slate-100">
<MessageSquareTextIcon className="h-4 w-4 text-slate-600" />
</div>
<div className="min-w-0">
<p className="text-xs text-slate-500">{t("environments.contacts.survey_response_created")}</p>
<Link
href={`/environments/${environment.id}/surveys/${survey.id}/summary`}
className="block truncate text-sm font-medium text-slate-700 hover:underline">
{survey.name}
</Link>
</div>
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="flex items-center justify-between p-4">
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-slate-100">
<MessageSquareTextIcon className="h-4 w-4 text-slate-600" />
</div>
<div className="flex items-center gap-1 text-sm text-slate-500">
<time className="px-1" dateTime={response.createdAt.toString()}>
{timeSince(response.createdAt.toString(), locale)}
</time>
{showDeleteButton &&
(canResponseBeDeleted ? (
<Button
variant="ghost"
size="icon"
onClick={onDeleteClick}
aria-label={t("environments.surveys.responses.delete_response")}>
<TrashIcon className="h-4 w-4" />
</Button>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
disabled
className="text-slate-400"
aria-label={t("environments.surveys.responses.delete_response")}>
<TrashIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
{t("environments.surveys.responses.this_response_is_in_progress")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
))}
<div className="min-w-0">
<p className="text-xs text-slate-500">
{t("environments.contacts.survey_response_created")}
</p>
<Link
href={`/environments/${environment.id}/surveys/${survey.id}/summary`}
className="block truncate text-sm font-medium text-slate-700 hover:underline">
{survey.name}
</Link>
</div>
</div>
<div className="flex items-center gap-1 text-sm text-slate-500">
<time className="px-1" dateTime={response.createdAt.toString()}>
{timeSince(response.createdAt.toString(), locale)}
</time>
{showDeleteButton &&
(canResponseBeDeleted ? (
<Button
variant="ghost"
size="icon"
onClick={() => setDeleteDialogOpen(true)}
aria-label={t("environments.surveys.responses.delete_response")}>
<TrashIcon className="h-4 w-4" />
</Button>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
disabled
className="text-slate-400"
aria-label={t("environments.surveys.responses.delete_response")}>
<TrashIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
{t("environments.surveys.responses.this_response_is_in_progress")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
))}
<Button
variant="ghost"
size="icon"
onClick={() => setIsExpanded((prev) => !prev)}
aria-expanded={isExpanded}
aria-controls={bodyId}
aria-label={
isExpanded
? t("environments.contacts.collapse_response")
: t("environments.contacts.expand_response")
}>
{isExpanded ? (
<ChevronUpIcon className="h-4 w-4 text-slate-400" />
) : (
<ChevronDownIcon className="h-4 w-4 text-slate-400" />
)}
</Button>
</div>
</div>
{isExpanded && (
<div id={bodyId}>
<SingleResponseCardBody
survey={surveyWithReplacedRecall}
response={response}
skippedQuestions={skippedQuestions}
locale={locale}
/>
<ResponseTagsWrapper
key={response.id}
environmentId={environment.id}
responseId={response.id}
tags={response.tags.map((tag) => ({ tagId: tag.id, tagName: tag.name }))}
environmentTags={environmentTags}
updateFetchedResponses={updateFetchedResponses}
isReadOnly={isReadOnly}
response={response}
locale={locale}
/>
</div>
)}
/>
<DeleteDialog
open={deleteDialogOpen}
setOpen={setDeleteDialogOpen}
deleteWhat={t("common.response")}
onDelete={handleDeleteResponse}
isDeleting={isDeleting}
text={t("environments.surveys.responses.delete_response_confirmation")}>
{hasQuotas && (
<DecrementQuotasCheckbox
title={t("environments.surveys.responses.delete_response_quotas")}
checked={decrementQuotas}
onCheckedChange={setDecrementQuotas}
/>
)}
</DeleteDialog>
</div>
);
};
@@ -8,7 +8,6 @@ interface IconAction {
onClick?: () => void;
isVisible?: boolean;
disabled?: boolean;
iconClassName?: string;
}
interface IconBarProps {
@@ -17,30 +16,30 @@ interface IconBarProps {
}
export const IconBar = ({ actions }: IconBarProps) => {
const visibleActions = actions.filter((action) => action.isVisible);
if (visibleActions.length === 0) return null;
if (actions.length === 0) return null;
return (
<div
className="flex items-center justify-center divide-x rounded-md border border-slate-300 bg-white"
role="toolbar"
aria-label="Action buttons">
{visibleActions.map((action, index) => (
<span key={`${action.tooltip}-${index}`}>
<TooltipRenderer tooltipContent={action.tooltip}>
<Button
variant="ghost"
className="border-none hover:bg-slate-50"
size="icon"
onClick={action.onClick}
disabled={action.disabled}
aria-label={action.tooltip}>
<action.icon className={action.iconClassName} />
</Button>
</TooltipRenderer>
</span>
))}
{actions
.filter((action) => action.isVisible)
.map((action, index) => (
<span key={`${action.tooltip}-${index}`}>
<TooltipRenderer tooltipContent={action.tooltip}>
<Button
variant="ghost"
className="border-none hover:bg-slate-50"
size="icon"
onClick={action.onClick}
disabled={action.disabled}
aria-label={action.tooltip}>
<action.icon />
</Button>
</TooltipRenderer>
</span>
))}
</div>
);
};
+25 -22
View File
@@ -9,6 +9,7 @@ Formbricks applies request rate limits to protect against abuse and keep API usa
Rate limits are scoped by identifier, depending on the endpoint:
- IP hash (for unauthenticated/client-side routes and public actions)
- Environment ID (for public client storage upload abuse protection)
- API key ID (for authenticated API calls)
- User ID (for authenticated session-based calls and server actions)
- Organization ID (for follow-up email dispatch)
@@ -19,29 +20,30 @@ When a limit is exceeded, the API returns `429 Too Many Requests`.
These are the current limits for Management APIs:
| **Route Group** | **Limit** | **Window** | **Identifier** |
| --- | --- | --- | --- |
| `/api/v1/management/*` (except `/api/v1/management/storage`), `/api/v1/webhooks/*`, `/api/v1/integrations/*`, `/api/v1/management/me` | 100 requests | 1 minute | API key ID or session user ID |
| `/api/v2/management/*` (and other v2 authenticated routes that use `authenticatedApiClient`) | 100 requests | 1 minute | API key ID |
| `POST /api/v1/management/storage` | 5 requests | 1 minute | API key ID or session user ID |
| **Route Group** | **Limit** | **Window** | **Identifier** |
| ------------------------------------------------------------------------------------------------------------------------------------- | ------------ | ---------- | ----------------------------- |
| `/api/v1/management/*` (except `/api/v1/management/storage`), `/api/v1/webhooks/*`, `/api/v1/integrations/*`, `/api/v1/management/me` | 100 requests | 1 minute | API key ID or session user ID |
| `/api/v2/management/*` (and other v2 authenticated routes that use `authenticatedApiClient`) | 100 requests | 1 minute | API key ID |
| `POST /api/v1/management/storage` | 5 requests | 1 minute | API key ID or session user ID |
## All Enforced Limits
| **Config** | **Limit** | **Window** | **Identifier** | **Used For** |
| --- | --- | --- | --- | --- |
| `auth.login` | 10 requests | 15 minutes | IP hash | Email/password login flow (`/api/auth/callback/credentials`) |
| `auth.signup` | 30 requests | 60 minutes | IP hash | Signup server action |
| `auth.forgotPassword` | 5 requests | 60 minutes | IP hash | Forgot password server action |
| `auth.verifyEmail` | 10 requests | 60 minutes | IP hash | Email verification callback + resend verification action |
| `api.v1` | 100 requests | 1 minute | API key ID or session user ID | v1 management, webhooks, integrations, and `/api/v1/management/me` |
| `api.v2` | 100 requests | 1 minute | API key ID | v2 authenticated API wrapper (`authenticatedApiClient`) |
| `api.client` | 100 requests | 1 minute | IP hash | v1 client API routes (except `/api/v1/client/og` and storage upload override), plus v2 routes that re-use those v1 handlers |
| `storage.upload` | 5 requests | 1 minute | IP hash or authenticated ID | Client storage upload and management storage upload |
| `storage.delete` | 5 requests | 1 minute | API key ID or session user ID | `DELETE /storage/[environmentId]/[accessType]/[fileName]` |
| `actions.emailUpdate` | 3 requests | 60 minutes | User ID | Profile email update action |
| `actions.surveyFollowUp` | 50 requests | 60 minutes | Organization ID | Survey follow-up email processing |
| `actions.sendLinkSurveyEmail` | 10 requests | 60 minutes | IP hash | Link survey email send action |
| `actions.licenseRecheck` | 5 requests | 1 minute | User ID | Enterprise license recheck action |
| **Config** | **Limit** | **Window** | **Identifier** | **Used For** |
| ------------------------------ | ------------ | ---------- | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `auth.login` | 10 requests | 15 minutes | IP hash | Email/password login flow (`/api/auth/callback/credentials`) |
| `auth.signup` | 30 requests | 60 minutes | IP hash | Signup server action |
| `auth.forgotPassword` | 5 requests | 60 minutes | IP hash | Forgot password server action |
| `auth.verifyEmail` | 10 requests | 60 minutes | IP hash | Email verification callback + resend verification action |
| `api.v1` | 100 requests | 1 minute | API key ID or session user ID | v1 management, webhooks, integrations, and `/api/v1/management/me` |
| `api.v2` | 100 requests | 1 minute | API key ID | v2 authenticated API wrapper (`authenticatedApiClient`) |
| `api.client` | 100 requests | 1 minute | IP hash | v1 client API routes (except `/api/v1/client/og` and storage upload override), plus v2 routes that re-use those v1 handlers |
| `storage.upload` | 5 requests | 1 minute | IP hash or authenticated ID | Client storage upload and management storage upload |
| `storage.uploadPerEnvironment` | 100 requests | 1 minute | Environment ID | Client storage upload only (`/api/v1/client/[environmentId]/storage` and the v2 re-export) |
| `storage.delete` | 5 requests | 1 minute | API key ID or session user ID | `DELETE /storage/[environmentId]/[accessType]/[fileName]` |
| `actions.emailUpdate` | 3 requests | 60 minutes | User ID | Profile email update action |
| `actions.surveyFollowUp` | 50 requests | 60 minutes | Organization ID | Survey follow-up email processing |
| `actions.sendLinkSurveyEmail` | 10 requests | 60 minutes | IP hash | Link survey email send action |
| `actions.licenseRecheck` | 5 requests | 1 minute | User ID | Enterprise license recheck action |
## Current Endpoint Exceptions
@@ -59,8 +61,8 @@ v1-style endpoints return:
```json
{
"code": "too_many_requests",
"message": "Maximum number of requests reached. Please try again later.",
"details": {}
"details": {},
"message": "Maximum number of requests reached. Please try again later."
}
```
@@ -91,4 +93,5 @@ After changing this value, restart the server.
- Redis/Valkey is required for robust rate limiting (`REDIS_URL`).
- If Redis is unavailable at runtime, rate-limiter checks currently fail open (requests are allowed through without enforcement).
- Client storage upload rate limits count signed upload URL issuance, not successful object creation in S3-compatible storage.
- Authentication failure audit logging uses a separate throttle (`shouldLogAuthFailure()`) and is intentionally **fail-closed**: when Redis is unavailable or errors occur, audit log entries are **skipped entirely** rather than written without throttle control. This prevents spam while preserving the hash-integrity chain required for compliance. In other words, if Redis is down, no authentication-failure audit logs will be recorded—requests themselves are still allowed (fail-open rate limiting above), but the audit trail for those failures will not be written.
+2
View File
@@ -44,10 +44,12 @@ checksums:
errors/file_input/duplicate_files: 198dd29e67beb6abc5b2534ede7d7f68
errors/file_input/file_size_exceeded: 072045b042a39fa1df76200f8fa36dd4
errors/file_input/file_size_exceeded_alert: d8e482a2ff05e78bbacaed9e9db9b5eb
errors/file_input/invalid_file_name: 9f9a632eaf77ef92552f755f43e7b25d
errors/file_input/no_valid_file_types_selected: 795acdedcffbcf06e57ea93fc16771ce
errors/file_input/only_one_file_can_be_uploaded_at_a_time: 1eda42bd46887f9702049e23fa7cb127
errors/file_input/placeholder_text: 15b61e390b6c5501d3e3b9da9f6c7930
errors/file_input/upload_failed: 735fdfc1a37ab035121328237ddd6fd0
errors/file_input/upload_service_unavailable: cd67a5c3ea1e6ff10636a7eec9f98740
errors/file_input/uploading: baef62e2015a34d6747ed6e4192a27b1
errors/file_input/you_can_only_upload_a_maximum_of_files: 72fe144f81075e5b06bae53b3a84d4db
errors/invalid_device_error/message: 8813dcd0e3e41934af18d7a15f8c83f4