Merge branch 'main' of https://github.com/formbricks/formbricks into feat/attribute-type-date

This commit is contained in:
Johannes
2025-12-12 22:54:02 +01:00
52 changed files with 1236 additions and 628 deletions

View File

@@ -3,14 +3,10 @@ name: E2E Tests
on:
workflow_call:
secrets:
AZURE_CLIENT_ID:
required: false
AZURE_TENANT_ID:
required: false
AZURE_SUBSCRIPTION_ID:
required: false
PLAYWRIGHT_SERVICE_URL:
required: false
PLAYWRIGHT_SERVICE_ACCESS_TOKEN:
required: false
ENTERPRISE_LICENSE_KEY:
required: true
# Add other secrets if necessary
@@ -21,7 +17,6 @@ env:
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
permissions:
id-token: write
contents: read
actions: read
@@ -114,7 +109,7 @@ jobs:
- name: Start MinIO Server
run: |
set -euo pipefail
# Start MinIO server in background
docker run -d \
--name minio-server \
@@ -124,7 +119,7 @@ jobs:
-e MINIO_ROOT_PASSWORD=devminio123 \
minio/minio:RELEASE.2025-09-07T16-13-09Z \
server /data --console-address :9001
echo "MinIO server started"
- name: Wait for MinIO and create S3 bucket
@@ -207,32 +202,30 @@ jobs:
- name: Install Playwright
run: pnpm exec playwright install --with-deps
- name: Set Azure Secret Variables
run: |
if [[ -n "${{ secrets.AZURE_CLIENT_ID }}" && -n "${{ secrets.AZURE_TENANT_ID }}" && -n "${{ secrets.AZURE_SUBSCRIPTION_ID }}" ]]; then
echo "AZURE_ENABLED=true" >> $GITHUB_ENV
else
echo "AZURE_ENABLED=false" >> $GITHUB_ENV
fi
- name: Azure login
if: env.AZURE_ENABLED == 'true'
uses: azure/login@a65d910e8af852a8061c627c456678983e180302 # v2.2.0
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Run E2E Tests (Azure)
if: env.AZURE_ENABLED == 'true'
- name: Determine Playwright execution mode
shell: bash
env:
PLAYWRIGHT_SERVICE_URL: ${{ secrets.PLAYWRIGHT_SERVICE_URL }}
CI: true
PLAYWRIGHT_SERVICE_ACCESS_TOKEN: ${{ secrets.PLAYWRIGHT_SERVICE_ACCESS_TOKEN }}
run: |
pnpm test-e2e:azure
set -euo pipefail
if [[ -n "${PLAYWRIGHT_SERVICE_URL}" && -n "${PLAYWRIGHT_SERVICE_ACCESS_TOKEN}" ]]; then
echo "PW_MODE=service" >> "$GITHUB_ENV"
else
echo "PW_MODE=local" >> "$GITHUB_ENV"
fi
- name: Run E2E Tests (Playwright Service)
if: env.PW_MODE == 'service'
env:
PLAYWRIGHT_SERVICE_URL: ${{ secrets.PLAYWRIGHT_SERVICE_URL }}
PLAYWRIGHT_SERVICE_ACCESS_TOKEN: ${{ secrets.PLAYWRIGHT_SERVICE_ACCESS_TOKEN }}
CI: true
run: pnpm test-e2e:azure
- name: Run E2E Tests (Local)
if: env.AZURE_ENABLED == 'false'
if: env.PW_MODE == 'local'
env:
CI: true
run: |

View File

@@ -135,7 +135,7 @@ export const OrganizationBreadcrumb = ({
},
{
id: "teams",
label: t("common.teams"),
label: t("common.members_and_teams"),
href: `/environments/${currentEnvironmentId}/settings/teams`,
},
{

View File

@@ -36,7 +36,7 @@ export const OrganizationSettingsNavbar = ({
},
{
id: "teams",
label: t("common.teams"),
label: t("common.members_and_teams"),
href: `/environments/${environmentId}/settings/teams`,
current: pathname?.includes("/teams"),
},

View File

@@ -234,6 +234,7 @@ checksums:
common/maximum: 4c07541dd1f093775bdc61b559cca6c8
common/member: 1606dc30b369856b9dba1fe9aec425d2
common/members: 0932e80cba1e3e0a7f52bb67ff31da32
common/members_and_teams: bf5c3fadcb9fc23533ec1532b805ac08
common/membership_not_found: 7ac63584af23396aace9992ad919ffd4
common/metadata: 695d4f7da261ba76e3be4de495491028
common/minimum: d9759235086d0169928b3c1401115e22
@@ -313,6 +314,7 @@ checksums:
common/read_docs: 426ba960bfedf186a878b7467867f9d2
common/recipients: f90e7f266be3f5a724858f21a9fd855e
common/remove: dba2fe5fe9f83f8078c687f28cba4b52
common/remove_from_team: 69bcc7a1001c3017f9de578ee22cffd6
common/reorder_and_hide_columns: a5e3d7c0c7ef879211d05a37be1c5069
common/report_survey: 147dd05db52e35f5d1f837460fb720f5
common/request_pricing: 58eb24af4f098632709cb7482b70a1cb
@@ -380,7 +382,8 @@ checksums:
common/team_access: 45c6232c71b760eaa33b932dabab4c1c
common/team_id: 134e32d6f7184577a46b2fd83e85e532
common/team_name: 549d949de4b9adad4afd6427a60a329e
common/teams: a2fbdec69342366a2b6033d119aa279a
common/team_role: 66db395781aef64ef3791417b3b67c0b
common/teams: b63448c05270497973ac4407047dae02
common/teams_not_found: 02f333a64a83c1c014d8900ec9666345
common/text: 4ddccc1974775ed7357f9beaf9361cec
common/time: b504a03d52e8001bfdc5cb6205364f42
@@ -840,7 +843,6 @@ checksums:
environments/project/tags/tags_merged: 544471de666f93fbb0ab600321d1e553
environments/project/teams/manage_teams: d7b5f26335cea450c333832adbe0b6ad
environments/project/teams/no_teams_found: fb6680d4b5b73731697b100713afb50d
environments/project/teams/only_organization_owners_and_managers_can_manage_teams: 179056fade669d34f63fb1ee965b8024
environments/project/teams/permission: cc2ed7274bd8267f9e0a10b079584d8b
environments/project/teams/team_name: d1a5f99dbf503ca53f06b3a98b511d02
environments/project/teams/team_settings_description: da32d77993f5c5c7547cdf3e1d3fc7d5
@@ -1086,13 +1088,17 @@ checksums:
environments/settings/teams/manage_team: 4c52e636cfd1451a08179fb7a68042ab
environments/settings/teams/manage_team_disabled: 2aaa0557b403a5bc657ec9e8b19ac5ac
environments/settings/teams/manager_role_description: 39846863fa85ff8b1c6e4f354eb5018f
environments/settings/teams/member: 1606dc30b369856b9dba1fe9aec425d2
environments/settings/teams/member_role_description: 1c5deaece65798b74cc0d34525506c18
environments/settings/teams/member_role_info_message: 0a276eef3c3b907d6f396ebfdc693b12
environments/settings/teams/organization_role: 979b75fcc3696952e5922d659c839c10
environments/settings/teams/owner_role_description: 8f577e6f9d1368fed4eba5a91ffc8cbf
environments/settings/teams/please_fill_all_member_fields: 60e38d9906ec9a02a44d16c736bd9fe9
environments/settings/teams/please_fill_all_project_fields: 6712059df63c432ecd31f3c52b8e4d87
environments/settings/teams/read: 2494ca23d10e5b6381eb271aceeb5270
environments/settings/teams/read_write: 278a90dade128198d4c93ac00c345320
environments/settings/teams/select_member: 7f4a38312aabbbe3fe92756b57bd5d75
environments/settings/teams/select_project: 6e4f4a24178660851d9ae0874706be9f
environments/settings/teams/team_admin: 5df68214685738029af678ae1d5912bb
environments/settings/teams/team_created_successfully: 45f83048fcabf466551144858a761eca
environments/settings/teams/team_deleted_successfully: 972c86b0abe87f229f7bf1a691c0a253
@@ -1176,6 +1182,10 @@ checksums:
environments/surveys/edit/bold: 4d7306bc355ed2befd6a9237c5452ee6
environments/surveys/edit/brand_color: 84ddb5736deb9f5c081ffe4962a6c63e
environments/surveys/edit/brightness: 45425b6db1872225bfff71cf619d0e64
environments/surveys/edit/bulk_edit: 59bd1a55587c8cbad716afbf2509e5bb
environments/surveys/edit/bulk_edit_description: 9b5b2c6183c6c51689e16d7ba02ec9bb
environments/surveys/edit/bulk_edit_options: 74ebec7c53be729f33e38d7605b25815
environments/surveys/edit/bulk_edit_options_for: 986af3a8286f34c9e4ad7c74d3c65ada
environments/surveys/edit/button_external: d2de24e06574622baf1c0cdd1b718b1a
environments/surveys/edit/button_external_description: cbd10d494a70b362bfee811e012c45b1
environments/surveys/edit/button_label: db3cd7c74f393187bd780c5c3d8b9b4f
@@ -1422,6 +1432,7 @@ checksums:
environments/surveys/edit/option_used_in_logic_error: c682ac2cfd286c3cc07dd21ac863dd4c
environments/surveys/edit/optional: 396fb9a0472daf401c392bdc3e248943
environments/surveys/edit/options: 59156082418d80acb211f973b1218f11
environments/surveys/edit/options_used_in_logic_bulk_error: 1720e7a01a0bcb67c152cfe6a68c5355
environments/surveys/edit/override_theme_with_individual_styles_for_this_survey: edffc97f5d3372419fe0444de0a5aa3f
environments/surveys/edit/overwrite_global_waiting_time: 7bc23bd502b6bd048356b67acd956d9d
environments/surveys/edit/overwrite_global_waiting_time_description: 795cf6e93d4c01d2e43aa0ebab601c6e
@@ -1569,6 +1580,7 @@ checksums:
environments/surveys/edit/unsaved_changes_warning: a164f276c9f7344022aa4640b32abcf9
environments/surveys/edit/until_they_submit_a_response: 2a0fd5dcc6cc40a72ed9b974f22eaf68
environments/surveys/edit/untitled_block: fdaa045139deff5cc65fa027df0cc22e
environments/surveys/edit/update_options: 3499161b010acdefba2d878daa5fb6fa
environments/surveys/edit/upgrade_notice_description: 32b66a4f257ad8d38bc38dcc95fe23c4
environments/surveys/edit/upgrade_notice_title: 40866066ebc558ad0c92a4f19f12090c
environments/surveys/edit/upload: 4a6c84aa16db0f4e5697f49b45257bc7

View File

@@ -261,6 +261,7 @@
"maximum": "Maximal",
"member": "Mitglied",
"members": "Mitglieder",
"members_and_teams": "Mitglieder & Teams",
"membership_not_found": "Mitgliedschaft nicht gefunden",
"metadata": "Metadaten",
"minimum": "Minimum",
@@ -340,6 +341,7 @@
"read_docs": "Dokumentation lesen",
"recipients": "Empfänger",
"remove": "Entfernen",
"remove_from_team": "Aus Team entfernen",
"reorder_and_hide_columns": "Spalten neu anordnen und ausblenden",
"report_survey": "Umfrage melden",
"request_pricing": "Preise anfragen",
@@ -407,7 +409,8 @@
"team_access": "Teamzugriff",
"team_id": "Team-ID",
"team_name": "Teamname",
"teams": "Zugriffskontrolle",
"team_role": "Team-Rolle",
"teams": "Teams",
"teams_not_found": "Teams nicht gefunden",
"text": "Text",
"time": "Zeit",
@@ -903,7 +906,6 @@
"teams": {
"manage_teams": "Teams verwalten",
"no_teams_found": "Keine Teams gefunden",
"only_organization_owners_and_managers_can_manage_teams": "Nur Organisationsinhaber und -manager können Teams verwalten.",
"permission": "Berechtigung",
"team_name": "Teamname",
"team_settings_description": "Teams und ihre Mitglieder können auf dieses Projekt und seine Umfragen zugreifen. Organisationsbesitzer und Manager können diesen Zugriff gewähren."
@@ -1167,13 +1169,17 @@
"manage_team": "Team verwalten",
"manage_team_disabled": "Nur Organisationsbesitzer, Manager und Team-Admins können Teams verwalten.",
"manager_role_description": "Manager können auf alle Projekte zugreifen und Mitglieder hinzufügen und entfernen.",
"member": "Mitglied",
"member_role_description": "Mitglieder können in ausgewählten Projekten arbeiten.",
"member_role_info_message": "Um neuen Mitgliedern Zugriff auf ein Projekt zu geben, füge sie bitte unten einem Team hinzu. Mit Teams kannst du steuern, wer auf welches Projekt zugreifen kann.",
"organization_role": "Organisationsrolle",
"owner_role_description": "Besitzer haben die volle Kontrolle über die Organisation.",
"please_fill_all_member_fields": "Bitte fülle alle Felder aus, um ein neues Mitglied hinzuzufügen.",
"please_fill_all_project_fields": "Bitte fülle alle Felder aus, um ein neues Projekt hinzuzufügen.",
"read": "Lesen",
"read_write": "Lesen & Schreiben",
"select_member": "Mitglied auswählen",
"select_project": "Projekt auswählen",
"team_admin": "Team-Admin",
"team_created_successfully": "Team erfolgreich erstellt.",
"team_deleted_successfully": "Team erfolgreich gelöscht.",
@@ -1261,6 +1267,10 @@
"bold": "Fett",
"brand_color": "Markenfarbe",
"brightness": "Helligkeit",
"bulk_edit": "Massenbearbeitung",
"bulk_edit_description": "Bearbeiten Sie alle Optionen unten, eine pro Zeile. Leere Zeilen werden übersprungen und Duplikate entfernt.",
"bulk_edit_options": "Optionen massenbearbeiten",
"bulk_edit_options_for": "Optionen massenbearbeiten für {language}",
"button_external": "Externen Link aktivieren",
"button_external_description": "Fügen Sie eine Schaltfläche hinzu, die eine externe URL in einem neuen Tab öffnet",
"button_label": "Beschriftung",
@@ -1507,6 +1517,7 @@
"option_used_in_logic_error": "Diese Option wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne sie zuerst aus der Logik.",
"optional": "Optional",
"options": "Optionen",
"options_used_in_logic_bulk_error": "Die folgenden Optionen werden in der Logik verwendet: {questionIndexes}. Bitte entferne sie zuerst aus der Logik.",
"override_theme_with_individual_styles_for_this_survey": "Styling für diese Umfrage überschreiben.",
"overwrite_global_waiting_time": "Benutzerdefinierte Wartezeit festlegen",
"overwrite_global_waiting_time_description": "Die Projektkonfiguration nur für diese Umfrage überschreiben.",
@@ -1656,6 +1667,7 @@
"unsaved_changes_warning": "Du hast ungespeicherte Änderungen in deiner Umfrage. Möchtest Du sie speichern, bevor Du gehst?",
"until_they_submit_a_response": "Fragen, bis sie eine Antwort abgeben",
"untitled_block": "Unbenannter Block",
"update_options": "Optionen aktualisieren",
"upgrade_notice_description": "Erstelle mehrsprachige Umfragen und entdecke viele weitere Funktionen",
"upgrade_notice_title": "Schalte mehrsprachige Umfragen mit einem höheren Plan frei",
"upload": "Hochladen",

View File

@@ -261,6 +261,7 @@
"maximum": "Maximum",
"member": "Member",
"members": "Members",
"members_and_teams": "Members & Teams",
"membership_not_found": "Membership not found",
"metadata": "Metadata",
"minimum": "Minimum",
@@ -337,9 +338,10 @@
"quota": "Quota",
"quotas": "Quotas",
"quotas_description": "Limit the amount of responses you receive from participants who meet certain criteria.",
"read_docs": "Read Docs",
"read_docs": "Read docs",
"recipients": "Recipients",
"remove": "Remove",
"remove_from_team": "Remove from team",
"reorder_and_hide_columns": "Reorder and hide columns",
"report_survey": "Report Survey",
"request_pricing": "Request Pricing",
@@ -349,7 +351,6 @@
"responses": "Responses",
"restart": "Restart",
"role": "Role",
"role_organization": "Role (Organization)",
"saas": "SaaS",
"sales": "Sales",
"save": "Save",
@@ -407,7 +408,8 @@
"team_access": "Team Access",
"team_id": "Team ID",
"team_name": "Team name",
"teams": "Access Control",
"team_role": "Team role",
"teams": "Teams",
"teams_not_found": "Teams not found",
"text": "Text",
"time": "Time",
@@ -903,7 +905,6 @@
"teams": {
"manage_teams": "Manage teams",
"no_teams_found": "No teams found",
"only_organization_owners_and_managers_can_manage_teams": "Only organization owners and managers can manage teams.",
"permission": "Permission",
"team_name": "Team Name",
"team_settings_description": "See which teams can access this project."
@@ -1167,13 +1168,17 @@
"manage_team": "Manage team",
"manage_team_disabled": "Only organization owners, managers and team admins can manage teams.",
"manager_role_description": "Managers can access all projects and add and remove members.",
"member": "Member",
"member_role_description": "Members can work in selected projects.",
"member_role_info_message": "To give new members access to a project, please add them to a Team below. With Teams you can manage who has access to which project.",
"organization_role": "Organization role",
"owner_role_description": "Owners have full control over the organization.",
"please_fill_all_member_fields": "Please fill all the fields to add a new member.",
"please_fill_all_project_fields": "Please fill all the fields to add a new project.",
"read": "Read",
"read_write": "Read & Write",
"select_member": "Select member",
"select_project": "Select project",
"team_admin": "Team Admin",
"team_created_successfully": "Team created successfully.",
"team_deleted_successfully": "Team deleted successfully.",
@@ -1261,6 +1266,10 @@
"bold": "Bold",
"brand_color": "Brand color",
"brightness": "Brightness",
"bulk_edit": "Bulk edit",
"bulk_edit_description": "Edit all options below, one per line. Empty lines will be skipped and duplicates removed.",
"bulk_edit_options": "Bulk edit options",
"bulk_edit_options_for": "Bulk edit options for {language}",
"button_external": "Enable External Link",
"button_external_description": "Add a button that opens an external URL in a new tab",
"button_label": "Button Label",
@@ -1507,6 +1516,7 @@
"option_used_in_logic_error": "This option is used in logic of question {questionIndex}. Please remove it from logic first.",
"optional": "Optional",
"options": "Options",
"options_used_in_logic_bulk_error": "The following options are used in logic: {questionIndexes}. Please remove them from logic first.",
"override_theme_with_individual_styles_for_this_survey": "Override the theme with individual styles for this survey.",
"overwrite_global_waiting_time": "Set custom waiting time",
"overwrite_global_waiting_time_description": "Override the project configuration for this survey only.",
@@ -1656,6 +1666,7 @@
"unsaved_changes_warning": "You have unsaved changes in your survey. Would you like to save them before leaving?",
"until_they_submit_a_response": "Ask until they submit a response",
"untitled_block": "Untitled Block",
"update_options": "Update options",
"upgrade_notice_description": "Create multilingual surveys and unlock many more features",
"upgrade_notice_title": "Unlock multi-language surveys with a higher plan",
"upload": "Upload",

View File

@@ -261,6 +261,7 @@
"maximum": "Máximo",
"member": "Miembro",
"members": "Miembros",
"members_and_teams": "Miembros y equipos",
"membership_not_found": "Membresía no encontrada",
"metadata": "Metadatos",
"minimum": "Mínimo",
@@ -340,6 +341,7 @@
"read_docs": "Leer documentación",
"recipients": "Destinatarios",
"remove": "Eliminar",
"remove_from_team": "Eliminar del equipo",
"reorder_and_hide_columns": "Reordenar y ocultar columnas",
"report_survey": "Reportar encuesta",
"request_pricing": "Solicitar precios",
@@ -407,7 +409,8 @@
"team_access": "Acceso de equipo",
"team_id": "ID de equipo",
"team_name": "Nombre del equipo",
"teams": "Control de acceso",
"team_role": "Rol del equipo",
"teams": "Equipos",
"teams_not_found": "Equipos no encontrados",
"text": "Texto",
"time": "Hora",
@@ -903,7 +906,6 @@
"teams": {
"manage_teams": "Gestionar equipos",
"no_teams_found": "No se han encontrado equipos",
"only_organization_owners_and_managers_can_manage_teams": "Solo los propietarios y gestores de la organización pueden gestionar equipos.",
"permission": "Permiso",
"team_name": "Nombre del equipo",
"team_settings_description": "Consulta qué equipos pueden acceder a este proyecto."
@@ -1167,13 +1169,17 @@
"manage_team": "Gestionar equipo",
"manage_team_disabled": "Solo los propietarios de la organización, gestores y administradores de equipo pueden gestionar equipos.",
"manager_role_description": "Los gestores pueden acceder a todos los proyectos y añadir y eliminar miembros.",
"member": "Miembro",
"member_role_description": "Los miembros pueden trabajar en proyectos seleccionados.",
"member_role_info_message": "Para dar a los nuevos miembros acceso a un proyecto, por favor añádelos a un equipo a continuación. Con los equipos puedes gestionar quién tiene acceso a qué proyecto.",
"organization_role": "Rol en la organización",
"owner_role_description": "Los propietarios tienen control total sobre la organización.",
"please_fill_all_member_fields": "Por favor, rellena todos los campos para añadir un nuevo miembro.",
"please_fill_all_project_fields": "Por favor, rellena todos los campos para añadir un nuevo proyecto.",
"read": "Lectura",
"read_write": "Lectura y escritura",
"select_member": "Seleccionar miembro",
"select_project": "Seleccionar proyecto",
"team_admin": "Administrador de equipo",
"team_created_successfully": "Equipo creado con éxito.",
"team_deleted_successfully": "Equipo eliminado correctamente.",
@@ -1261,6 +1267,10 @@
"bold": "Negrita",
"brand_color": "Color de marca",
"brightness": "Brillo",
"bulk_edit": "Edición masiva",
"bulk_edit_description": "Edita todas las opciones a continuación, una por línea. Las líneas vacías se omitirán y los duplicados se eliminarán.",
"bulk_edit_options": "Edición masiva de opciones",
"bulk_edit_options_for": "Edición masiva de opciones para {language}",
"button_external": "Habilitar enlace externo",
"button_external_description": "Añadir un botón que abre una URL externa en una nueva pestaña",
"button_label": "Etiqueta del botón",
@@ -1507,6 +1517,7 @@
"option_used_in_logic_error": "Esta opción se utiliza en la lógica de la pregunta {questionIndex}. Por favor, elimínala de la lógica primero.",
"optional": "Opcional",
"options": "Opciones",
"options_used_in_logic_bulk_error": "Las siguientes opciones se utilizan en la lógica: {questionIndexes}. Por favor, elimínalas de la lógica primero.",
"override_theme_with_individual_styles_for_this_survey": "Anular el tema con estilos individuales para esta encuesta.",
"overwrite_global_waiting_time": "Establecer tiempo de espera personalizado",
"overwrite_global_waiting_time_description": "Anular la configuración del proyecto solo para esta encuesta.",
@@ -1656,6 +1667,7 @@
"unsaved_changes_warning": "Tienes cambios sin guardar en tu encuesta. ¿Quieres guardarlos antes de salir?",
"until_they_submit_a_response": "Preguntar hasta que envíen una respuesta",
"untitled_block": "Bloque sin título",
"update_options": "Actualizar opciones",
"upgrade_notice_description": "Crea encuestas multilingües y desbloquea muchas más funciones",
"upgrade_notice_title": "Desbloquea encuestas multilingües con un plan superior",
"upload": "Subir",

View File

@@ -261,6 +261,7 @@
"maximum": "Max",
"member": "Membre",
"members": "Membres",
"members_and_teams": "Membres & Équipes",
"membership_not_found": "Abonnement non trouvé",
"metadata": "Métadonnées",
"minimum": "Min",
@@ -340,6 +341,7 @@
"read_docs": "Lire les documents",
"recipients": "Destinataires",
"remove": "Retirer",
"remove_from_team": "Retirer de l'équipe",
"reorder_and_hide_columns": "Réorganiser et masquer des colonnes",
"report_survey": "Rapport d'enquête",
"request_pricing": "Connaître le tarif",
@@ -407,7 +409,8 @@
"team_access": "Accès",
"team_id": "Identifiant de l'équipe",
"team_name": "Nom de l'équipe",
"teams": "Contrôle d'accès",
"team_role": "Rôle dans l'équipe",
"teams": "Équipes",
"teams_not_found": "Équipes non trouvées",
"text": "Texte",
"time": "Temps",
@@ -903,7 +906,6 @@
"teams": {
"manage_teams": "Gérer les équipes",
"no_teams_found": "Aucune équipe trouvée",
"only_organization_owners_and_managers_can_manage_teams": "Seuls les propriétaires et les gestionnaires de l'organisation peuvent gérer les équipes.",
"permission": "Permission",
"team_name": "Nom de l'équipe",
"team_settings_description": "Vous pouvez consulter la liste des équipes qui ont accès à ce projet."
@@ -1167,13 +1169,17 @@
"manage_team": "Gérer l'équipe",
"manage_team_disabled": "Seuls les propriétaires de l'organisation, les gestionnaires et les administrateurs d'équipe peuvent gérer les équipes.",
"manager_role_description": "Les gestionnaires peuvent accéder à tous les projets et ajouter et supprimer des membres.",
"member": "Membre",
"member_role_description": "Les membres peuvent travailler sur des projets sélectionnés.",
"member_role_info_message": "Pour donner accès à un projet aux nouveaux membres, veuillez les ajouter à une équipe ci-dessous. Avec les équipes, vous pouvez gérer qui a accès à quel projet.",
"organization_role": "Rôle dans l'organisation",
"owner_role_description": "Les propriétaires ont un contrôle total sur l'organisation.",
"please_fill_all_member_fields": "Veuillez remplir tous les champs pour ajouter un nouveau membre.",
"please_fill_all_project_fields": "Veuillez remplir tous les champs pour ajouter un nouveau projet.",
"read": "Lire",
"read_write": "Lire et Écrire",
"select_member": "Sélectionner membre",
"select_project": "Sélectionner projet",
"team_admin": "Administrateur d'équipe",
"team_created_successfully": "Équipe créée avec succès.",
"team_deleted_successfully": "Équipe supprimée avec succès.",
@@ -1261,6 +1267,10 @@
"bold": "Gras",
"brand_color": "Couleur de marque",
"brightness": "Luminosité",
"bulk_edit": "Modification en masse",
"bulk_edit_description": "Modifiez toutes les options ci-dessous, une par ligne. Les lignes vides seront ignorées et les doublons supprimés.",
"bulk_edit_options": "Modifier les options en masse",
"bulk_edit_options_for": "Modifier les options en masse pour {language}",
"button_external": "Activer le lien externe",
"button_external_description": "Ajouter un bouton qui ouvre une URL externe dans un nouvel onglet",
"button_label": "Label du bouton",
@@ -1507,6 +1517,7 @@
"option_used_in_logic_error": "Cette option est utilisée dans la logique de la question {questionIndex}. Veuillez d'abord la supprimer de la logique.",
"optional": "Optionnel",
"options": "Options",
"options_used_in_logic_bulk_error": "Les options suivantes sont utilisées dans la logique: {questionIndexes}. Veuillez d'abord les supprimer de la logique.",
"override_theme_with_individual_styles_for_this_survey": "Override the theme with individual styles for this survey.",
"overwrite_global_waiting_time": "Définir un temps d'attente personnalisé",
"overwrite_global_waiting_time_description": "Remplacer la configuration du projet pour cette enquête uniquement.",
@@ -1656,6 +1667,7 @@
"unsaved_changes_warning": "Vous avez des modifications non enregistrées dans votre enquête. Souhaitez-vous les enregistrer avant de partir ?",
"until_they_submit_a_response": "Demander jusqu'à ce qu'ils soumettent une réponse",
"untitled_block": "Bloc sans titre",
"update_options": "Mettre à jour les options",
"upgrade_notice_description": "Créez des sondages multilingues et débloquez de nombreuses autres fonctionnalités",
"upgrade_notice_title": "Débloquez les sondages multilingues avec un plan supérieur",
"upload": "Télécharger",

View File

@@ -261,6 +261,7 @@
"maximum": "最大",
"member": "メンバー",
"members": "メンバー",
"members_and_teams": "メンバー&チーム",
"membership_not_found": "メンバーシップが見つかりません",
"metadata": "メタデータ",
"minimum": "最小",
@@ -340,6 +341,7 @@
"read_docs": "ドキュメントを読む",
"recipients": "受信者",
"remove": "削除",
"remove_from_team": "チームから削除",
"reorder_and_hide_columns": "列の並び替えと非表示",
"report_survey": "フォームを報告",
"request_pricing": "料金を問い合わせる",
@@ -407,7 +409,8 @@
"team_access": "チームアクセス",
"team_id": "チームID",
"team_name": "チーム名",
"teams": "アクセス制御",
"team_role": "チームの役割",
"teams": "チーム",
"teams_not_found": "チームが見つかりません",
"text": "テキスト",
"time": "時間",
@@ -903,7 +906,6 @@
"teams": {
"manage_teams": "チームを管理",
"no_teams_found": "チームが見つかりません",
"only_organization_owners_and_managers_can_manage_teams": "組織のオーナーまたは管理者のみがチームを管理できます。",
"permission": "権限",
"team_name": "チーム名",
"team_settings_description": "このプロジェクトにアクセスできるチームを確認します。"
@@ -1167,13 +1169,17 @@
"manage_team": "チームを管理",
"manage_team_disabled": "組織のオーナー、管理者、チーム管理者のみがチームを管理できます。",
"manager_role_description": "管理者はすべてのプロジェクトにアクセスでき、メンバーを追加および削除できます。",
"member": "メンバー",
"member_role_description": "メンバーは選択されたプロジェクトで作業できます。",
"member_role_info_message": "新しいメンバーにプロジェクトへのアクセス権を付与するには、以下のチームに追加してください。チームを使用すると、誰がどのプロジェクトにアクセスできるかを管理できます。",
"organization_role": "組織の役割",
"owner_role_description": "オーナーは組織を完全に制御できます。",
"please_fill_all_member_fields": "新しいメンバーを追加するには、すべてのフィールドを記入してください。",
"please_fill_all_project_fields": "新しいプロジェクトを追加するには、すべてのフィールドを記入してください。",
"read": "読み取り",
"read_write": "読み書き",
"select_member": "メンバーを選択",
"select_project": "プロジェクトを選択",
"team_admin": "チーム管理者",
"team_created_successfully": "チームを正常に作成しました。",
"team_deleted_successfully": "チームを正常に削除しました。",
@@ -1261,6 +1267,10 @@
"bold": "太字",
"brand_color": "ブランドカラー",
"brightness": "明るさ",
"bulk_edit": "一括編集",
"bulk_edit_description": "以下のオプションを1行ずつ編集してください。空の行はスキップされ、重複は削除されます。",
"bulk_edit_options": "オプションの一括編集",
"bulk_edit_options_for": "{language}のオプションを一括編集",
"button_external": "外部リンクを有効にする",
"button_external_description": "新しいタブで外部URLを開くボタンを追加する",
"button_label": "ボタンのラベル",
@@ -1507,6 +1517,7 @@
"option_used_in_logic_error": "このオプションは質問 {questionIndex} のロジックで使用されています。まず、ロジックから削除してください。",
"optional": "オプション",
"options": "オプション",
"options_used_in_logic_bulk_error": "以下のオプションはロジックで使用されています:{questionIndexes}。まず、ロジックから削除してください。",
"override_theme_with_individual_styles_for_this_survey": "このフォームの個別のスタイルでテーマを上書きします。",
"overwrite_global_waiting_time": "カスタム待機時間を設定する",
"overwrite_global_waiting_time_description": "このフォームのみプロジェクト設定を上書きします。",
@@ -1656,6 +1667,7 @@
"unsaved_changes_warning": "フォームに未保存の変更があります。離れる前に保存しますか?",
"until_they_submit_a_response": "回答が提出されるまで質問する",
"untitled_block": "無題のブロック",
"update_options": "オプションを更新",
"upgrade_notice_description": "多言語フォームを作成し、さらに多くの機能をアンロック",
"upgrade_notice_title": "上位プランで多言語フォームをアンロック",
"upload": "アップロード",

View File

@@ -261,6 +261,7 @@
"maximum": "Maximaal",
"member": "Lid",
"members": "Leden",
"members_and_teams": "Leden & teams",
"membership_not_found": "Lidmaatschap niet gevonden",
"metadata": "Metagegevens",
"minimum": "Minimum",
@@ -340,6 +341,7 @@
"read_docs": "Lees Documenten",
"recipients": "Ontvangers",
"remove": "Verwijderen",
"remove_from_team": "Verwijderen uit team",
"reorder_and_hide_columns": "Kolommen opnieuw rangschikken en verbergen",
"report_survey": "Verslag enquête",
"request_pricing": "Vraag prijzen aan",
@@ -407,7 +409,8 @@
"team_access": "Teamtoegang",
"team_id": "Team-ID",
"team_name": "Teamnaam",
"teams": "Toegangscontrole",
"team_role": "Teamrol",
"teams": "Teams",
"teams_not_found": "Teams niet gevonden",
"text": "Tekst",
"time": "Tijd",
@@ -903,7 +906,6 @@
"teams": {
"manage_teams": "Beheer teams",
"no_teams_found": "Geen teams gevonden",
"only_organization_owners_and_managers_can_manage_teams": "Alleen eigenaren en managers van organisaties kunnen teams beheren.",
"permission": "Toestemming",
"team_name": "Teamnaam",
"team_settings_description": "Bekijk welke teams toegang hebben tot dit project."
@@ -1167,13 +1169,17 @@
"manage_team": "Beheer team",
"manage_team_disabled": "Alleen organisatie-eigenaren, managers en teambeheerders kunnen teams beheren.",
"manager_role_description": "Managers hebben toegang tot alle projecten en kunnen leden toevoegen en verwijderen.",
"member": "Lid",
"member_role_description": "Leden kunnen in geselecteerde projecten werken.",
"member_role_info_message": "Om nieuwe leden toegang te geven tot een project, voegt u ze hieronder toe aan een team. Met Teams kun je beheren wie toegang heeft tot welk project.",
"organization_role": "Organisatierol",
"owner_role_description": "Eigenaars hebben volledige controle over de organisatie.",
"please_fill_all_member_fields": "Vul alle velden in om een nieuw lid toe te voegen.",
"please_fill_all_project_fields": "Vul alle velden in om een nieuw project toe te voegen.",
"read": "Lezen",
"read_write": "Lezen en schrijven",
"select_member": "Selecteer lid",
"select_project": "Selecteer project",
"team_admin": "Teambeheerder",
"team_created_successfully": "Team succesvol aangemaakt.",
"team_deleted_successfully": "Team succesvol verwijderd.",
@@ -1261,6 +1267,10 @@
"bold": "Vetgedrukt",
"brand_color": "Merk kleur",
"brightness": "Helderheid",
"bulk_edit": "Bulkbewerking",
"bulk_edit_description": "Bewerk alle opties hieronder, één per regel. Lege regels worden overgeslagen en duplicaten verwijderd.",
"bulk_edit_options": "Opties bulkbewerken",
"bulk_edit_options_for": "Opties bulkbewerken voor {language}",
"button_external": "Externe link inschakelen",
"button_external_description": "Voeg een knop toe die een externe URL in een nieuw tabblad opent",
"button_label": "Knoplabel",
@@ -1507,6 +1517,7 @@
"option_used_in_logic_error": "Deze optie wordt gebruikt in de logica van vraag {questionIndex}. Verwijder het eerst uit de logica.",
"optional": "Optioneel",
"options": "Opties",
"options_used_in_logic_bulk_error": "De volgende opties worden gebruikt in logica: {questionIndexes}. Verwijder ze eerst uit de logica.",
"override_theme_with_individual_styles_for_this_survey": "Overschrijf het thema met individuele stijlen voor deze enquête.",
"overwrite_global_waiting_time": "Stel aangepaste wachttijd in",
"overwrite_global_waiting_time_description": "Overschrijf de projectconfiguratie alleen voor deze enquête.",
@@ -1656,6 +1667,7 @@
"unsaved_changes_warning": "Er zijn niet-opgeslagen wijzigingen in uw enquête. Wilt u ze bewaren voordat u vertrekt?",
"until_they_submit_a_response": "Vraag totdat ze een reactie indienen",
"untitled_block": "Naamloos blok",
"update_options": "Opties bijwerken",
"upgrade_notice_description": "Creëer meertalige enquêtes en ontgrendel nog veel meer functies",
"upgrade_notice_title": "Ontgrendel meertalige enquêtes met een hoger plan",
"upload": "Uploaden",

View File

@@ -261,6 +261,7 @@
"maximum": "Máximo",
"member": "Membros",
"members": "Membros",
"members_and_teams": "Membros e equipes",
"membership_not_found": "Assinatura não encontrada",
"metadata": "metadados",
"minimum": "Mínimo",
@@ -340,6 +341,7 @@
"read_docs": "Ler Documentação",
"recipients": "Destinatários",
"remove": "remover",
"remove_from_team": "Remover da equipe",
"reorder_and_hide_columns": "Reordenar e ocultar colunas",
"report_survey": "Relatório de Pesquisa",
"request_pricing": "Solicitar Preços",
@@ -407,7 +409,8 @@
"team_access": "Acesso da equipe",
"team_id": "ID da Equipe",
"team_name": "Nome da equipe",
"teams": "Controle de Acesso",
"team_role": "Função na equipe",
"teams": "Equipes",
"teams_not_found": "Equipes não encontradas",
"text": "Texto",
"time": "tempo",
@@ -903,7 +906,6 @@
"teams": {
"manage_teams": "Gerenciar Equipes",
"no_teams_found": "Nenhuma equipe encontrada",
"only_organization_owners_and_managers_can_manage_teams": "Apenas proprietários e gerentes da organização podem gerenciar equipes.",
"permission": "Permissão",
"team_name": "Nome da equipe",
"team_settings_description": "As equipes e seus membros podem acessar este projeto e suas pesquisas. Proprietários e gerentes da organização podem conceder esse acesso."
@@ -1167,13 +1169,17 @@
"manage_team": "Gerenciar equipe",
"manage_team_disabled": "Apenas proprietários da organização, gerentes e administradores da equipe podem gerenciar equipes.",
"manager_role_description": "Os gerentes podem acessar todos os projetos e adicionar e remover membros.",
"member": "Membro",
"member_role_description": "Os membros podem trabalhar em projetos selecionados.",
"member_role_info_message": "Para dar acesso a novos membros a um projeto, por favor, adicione-os a uma equipe abaixo. Com equipes, você pode gerenciar quem tem acesso a qual projeto.",
"organization_role": "Função na organização",
"owner_role_description": "Os proprietários têm controle total sobre a organização.",
"please_fill_all_member_fields": "Por favor, preencha todos os campos para adicionar um novo membro.",
"please_fill_all_project_fields": "Por favor, preencha todos os campos para adicionar um novo projeto.",
"read": "Leitura",
"read_write": "Leitura & Escrita",
"select_member": "Selecionar membro",
"select_project": "Selecionar projeto",
"team_admin": "Administrador da equipe",
"team_created_successfully": "Equipe criada com sucesso.",
"team_deleted_successfully": "Equipe excluída com sucesso.",
@@ -1261,6 +1267,10 @@
"bold": "Negrito",
"brand_color": "Cor da marca",
"brightness": "brilho",
"bulk_edit": "Edição em massa",
"bulk_edit_description": "Edite todas as opções abaixo, uma por linha. Linhas vazias serão ignoradas e duplicatas removidas.",
"bulk_edit_options": "Editar opções em massa",
"bulk_edit_options_for": "Editar opções em massa para {language}",
"button_external": "Habilitar link externo",
"button_external_description": "Adicionar um botão que abre uma URL externa em uma nova aba",
"button_label": "Rótulo do Botão",
@@ -1507,6 +1517,7 @@
"option_used_in_logic_error": "Esta opção é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.",
"optional": "Opcional",
"options": "Opções",
"options_used_in_logic_bulk_error": "As seguintes opções são usadas na lógica: {questionIndexes}. Por favor, remova-as da lógica primeiro.",
"override_theme_with_individual_styles_for_this_survey": "Substitua o tema com estilos individuais para essa pesquisa.",
"overwrite_global_waiting_time": "Definir tempo de espera personalizado",
"overwrite_global_waiting_time_description": "Substituir a configuração do projeto apenas para esta pesquisa.",
@@ -1656,6 +1667,7 @@
"unsaved_changes_warning": "Você tem alterações não salvas na sua pesquisa. Quer salvar antes de sair?",
"until_they_submit_a_response": "Perguntar até que enviem uma resposta",
"untitled_block": "Bloco sem título",
"update_options": "Atualizar opções",
"upgrade_notice_description": "Crie pesquisas multilíngues e desbloqueie muitas outras funcionalidades",
"upgrade_notice_title": "Desbloqueie pesquisas multilíngues com um plano superior",
"upload": "Enviar",

View File

@@ -261,6 +261,7 @@
"maximum": "Máximo",
"member": "Membro",
"members": "Membros",
"members_and_teams": "Membros e equipas",
"membership_not_found": "Associação não encontrada",
"metadata": "Metadados",
"minimum": "Mínimo",
@@ -340,6 +341,7 @@
"read_docs": "Ler Documentos",
"recipients": "Destinatários",
"remove": "Remover",
"remove_from_team": "Remover da equipa",
"reorder_and_hide_columns": "Reordenar e ocultar colunas",
"report_survey": "Relatório de Inquérito",
"request_pricing": "Pedido de Preços",
@@ -407,7 +409,8 @@
"team_access": "Acesso da Equipa",
"team_id": "ID da Equipa",
"team_name": "Nome da equipa",
"teams": "Controlo de Acesso",
"team_role": "Função na equipa",
"teams": "Equipas",
"teams_not_found": "Equipas não encontradas",
"text": "Texto",
"time": "Tempo",
@@ -903,7 +906,6 @@
"teams": {
"manage_teams": "Gerir equipas",
"no_teams_found": "Nenhuma equipa encontrada",
"only_organization_owners_and_managers_can_manage_teams": "Apenas os proprietários e gestores da organização podem gerir equipas.",
"permission": "Permissão",
"team_name": "Nome da Equipa",
"team_settings_description": "Veja quais equipas podem aceder a este projeto."
@@ -1167,13 +1169,17 @@
"manage_team": "Gerir equipa",
"manage_team_disabled": "Apenas os proprietários da organização, gestores e administradores de equipa podem gerir equipas.",
"manager_role_description": "Os gestores podem aceder a todos os projetos e adicionar e remover membros.",
"member": "Membro",
"member_role_description": "Os membros podem trabalhar em projetos selecionados.",
"member_role_info_message": "Adicione os membros que deseja a uma Equipa abaixo. Nesta secção, pode gerir quem tem acesso a cada projeto.",
"organization_role": "Função na organização",
"owner_role_description": "Os proprietários têm controlo total sobre a organização.",
"please_fill_all_member_fields": "Por favor, preencha todos os campos para adicionar um novo membro.",
"please_fill_all_project_fields": "Por favor, preencha todos os campos para adicionar um novo projeto.",
"read": "Ler",
"read_write": "Ler e Escrever",
"select_member": "Selecionar membro",
"select_project": "Selecionar projeto",
"team_admin": "Administrador da Equipa",
"team_created_successfully": "Equipa criada com sucesso.",
"team_deleted_successfully": "Equipa eliminada com sucesso.",
@@ -1261,6 +1267,10 @@
"bold": "Negrito",
"brand_color": "Cor da marca",
"brightness": "Brilho",
"bulk_edit": "Edição em massa",
"bulk_edit_description": "Edite todas as opções abaixo, uma por linha. Linhas vazias serão ignoradas e duplicados removidos.",
"bulk_edit_options": "Editar opções em massa",
"bulk_edit_options_for": "Editar opções em massa para {language}",
"button_external": "Ativar link externo",
"button_external_description": "Adicionar um botão que abre um URL externo num novo separador",
"button_label": "Rótulo do botão",
@@ -1507,6 +1517,7 @@
"option_used_in_logic_error": "Esta opção é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.",
"optional": "Opcional",
"options": "Opções",
"options_used_in_logic_bulk_error": "As seguintes opções são usadas na lógica: {questionIndexes}. Por favor, remova-as da lógica primeiro.",
"override_theme_with_individual_styles_for_this_survey": "Substituir o tema com estilos individuais para este inquérito.",
"overwrite_global_waiting_time": "Definir tempo de espera personalizado",
"overwrite_global_waiting_time_description": "Substituir a configuração do projeto apenas para este inquérito.",
@@ -1656,6 +1667,7 @@
"unsaved_changes_warning": "Tem alterações não guardadas no seu inquérito. Gostaria de as guardar antes de sair?",
"until_they_submit_a_response": "Perguntar até que submetam uma resposta",
"untitled_block": "Bloco sem título",
"update_options": "Atualizar opções",
"upgrade_notice_description": "Crie inquéritos multilingues e desbloqueie muitas mais funcionalidades",
"upgrade_notice_title": "Desbloqueie inquéritos multilingues com um plano superior",
"upload": "Carregar",

View File

@@ -261,6 +261,7 @@
"maximum": "Maximum",
"member": "Membru",
"members": "Membri",
"members_and_teams": "Membri și echipe",
"membership_not_found": "Apartenența nu a fost găsită",
"metadata": "Metadate",
"minimum": "Minim",
@@ -340,6 +341,7 @@
"read_docs": "Citește documentația",
"recipients": "Destinatari",
"remove": "Șterge",
"remove_from_team": "Elimină din echipă",
"reorder_and_hide_columns": "Reordonați și ascundeți coloanele",
"report_survey": "Raportează chestionarul",
"request_pricing": "Solicită Prețuri",
@@ -407,7 +409,8 @@
"team_access": "Acces echipă",
"team_id": "ID echipă",
"team_name": "Nume echipă",
"teams": "Control acces",
"team_role": "Rol în echipă",
"teams": "Echipe",
"teams_not_found": "Echipele nu au fost găsite",
"text": "Text",
"time": "Timp",
@@ -903,7 +906,6 @@
"teams": {
"manage_teams": "Gestionați echipele",
"no_teams_found": "Nicio echipă găsită",
"only_organization_owners_and_managers_can_manage_teams": "Doar proprietarii de organizație și managerii pot gestiona echipele.",
"permission": "Permisiune",
"team_name": "Nume echipă",
"team_settings_description": "Vezi care echipe pot accesa acest proiect."
@@ -1167,13 +1169,17 @@
"manage_team": "Gestionați echipa",
"manage_team_disabled": "Doar proprietarii de organizații, managerii și administratorii de echipă pot gestiona echipele.",
"manager_role_description": "Managerii pot accesa toate proiectele și pot adăuga sau elimina membri.",
"member": "Membru",
"member_role_description": "Membrii pot lucra în proiectele selectate.",
"member_role_info_message": "Pentru a oferi membrilor noi acces la un proiect, vă rugăm să-i adăugați la o Echipă mai jos. Cu Echipe puteți gestiona cine are acces la ce proiect.",
"organization_role": "Rol în organizație",
"owner_role_description": "Proprietarii au control total asupra organizației.",
"please_fill_all_member_fields": "Vă rugăm să completați toate câmpurile pentru a adăuga un nou membru.",
"please_fill_all_project_fields": "Vă rugăm să completați toate câmpurile pentru a adăuga un proiect nou.",
"read": "Citește",
"read_write": "Citire & Scriere",
"select_member": "Selectează membrul",
"select_project": "Selectează proiectul",
"team_admin": "Administrator Echipe",
"team_created_successfully": "Echipă creată cu succes",
"team_deleted_successfully": "Echipă ștearsă cu succes.",
@@ -1261,6 +1267,10 @@
"bold": "Îngroșat",
"brand_color": "Culoarea brandului",
"brightness": "Luminozitate",
"bulk_edit": "Editare în bloc",
"bulk_edit_description": "Editați toate opțiunile de mai jos, câte una pe linie. Liniile goale vor fi omise, iar duplicatele vor fi eliminate.",
"bulk_edit_options": "Opțiuni de editare în bloc",
"bulk_edit_options_for": "Editare în bloc a opțiunilor pentru {language}",
"button_external": "Activează link extern",
"button_external_description": "Adaugă un buton care deschide un URL extern într-o filă nouă",
"button_label": "Etichetă buton",
@@ -1507,6 +1517,7 @@
"option_used_in_logic_error": "Această opțiune este folosită în logica întrebării {questionIndex}. Vă rugăm să-l eliminați din logică mai întâi.",
"optional": "Opțional",
"options": "Opțiuni",
"options_used_in_logic_bulk_error": "Următoarele opțiuni sunt folosite în logică: {questionIndexes}. Vă rugăm să le eliminați din logică mai întâi.",
"override_theme_with_individual_styles_for_this_survey": "Suprascrie tema cu stiluri individuale pentru acest sondaj.",
"overwrite_global_waiting_time": "Setează un timp de așteptare personalizat",
"overwrite_global_waiting_time_description": "Suprascrie configurația proiectului doar pentru acest sondaj.",
@@ -1656,6 +1667,7 @@
"unsaved_changes_warning": "Aveți modificări nesalvate în sondajul dumneavoastră. Doriți să le salvați înainte de a pleca?",
"until_they_submit_a_response": "Întreabă până când trimit un răspuns",
"untitled_block": "Bloc fără titlu",
"update_options": "Actualizați opțiunile",
"upgrade_notice_description": "Creați sondaje multilingve și deblocați multe alte caracteristici",
"upgrade_notice_title": "Deblocați sondajele multilingve cu un plan superior",
"upload": "Încărcați",

View File

@@ -261,6 +261,7 @@
"maximum": "Maximum",
"member": "Medlem",
"members": "Medlemmar",
"members_and_teams": "Medlemmar och team",
"membership_not_found": "Medlemskap hittades inte",
"metadata": "Metadata",
"minimum": "Minimum",
@@ -340,6 +341,7 @@
"read_docs": "Läs dokumentation",
"recipients": "Mottagare",
"remove": "Ta bort",
"remove_from_team": "Ta bort från teamet",
"reorder_and_hide_columns": "Ordna om och dölj kolumner",
"report_survey": "Rapportera enkät",
"request_pricing": "Begär prissättning",
@@ -407,6 +409,7 @@
"team_access": "Teamåtkomst",
"team_id": "Team-ID",
"team_name": "Teamnamn",
"team_role": "Teamroll",
"teams": "Åtkomstkontroll",
"teams_not_found": "Team hittades inte",
"text": "Text",
@@ -903,7 +906,6 @@
"teams": {
"manage_teams": "Hantera team",
"no_teams_found": "Inga team hittades",
"only_organization_owners_and_managers_can_manage_teams": "Endast organisationsägare och administratörer kan hantera team.",
"permission": "Behörighet",
"team_name": "Teamnamn",
"team_settings_description": "Se vilka team som kan komma åt detta projekt."
@@ -1167,13 +1169,17 @@
"manage_team": "Hantera team",
"manage_team_disabled": "Endast organisationsägare, administratörer och teamadministratörer kan hantera team.",
"manager_role_description": "Administratörer kan komma åt alla projekt och lägga till och ta bort medlemmar.",
"member": "Medlem",
"member_role_description": "Medlemmar kan arbeta i valda projekt.",
"member_role_info_message": "För att ge nya medlemmar åtkomst till ett projekt, vänligen lägg till dem i ett team nedan. Med team kan du hantera vem som har åtkomst till vilket projekt.",
"organization_role": "Organisationsroll",
"owner_role_description": "Ägare har full kontroll över organisationen.",
"please_fill_all_member_fields": "Vänligen fyll i alla fält för att lägga till en ny medlem.",
"please_fill_all_project_fields": "Vänligen fyll i alla fält för att lägga till ett nytt projekt.",
"read": "Läs",
"read_write": "Läs och skriv",
"select_member": "Välj medlem",
"select_project": "Välj projekt",
"team_admin": "Teamadministratör",
"team_created_successfully": "Team skapat.",
"team_deleted_successfully": "Team borttaget.",
@@ -1261,6 +1267,10 @@
"bold": "Fet",
"brand_color": "Varumärkesfärg",
"brightness": "Ljusstyrka",
"bulk_edit": "Massredigera",
"bulk_edit_description": "Redigera alla alternativ nedan, ett per rad. Tomma rader kommer att hoppas över och dubbletter tas bort.",
"bulk_edit_options": "Massredigera alternativ",
"bulk_edit_options_for": "Massredigera alternativ för {language}",
"button_external": "Aktivera extern länk",
"button_external_description": "Lägg till en knapp som öppnar en extern URL i en ny flik",
"button_label": "Knappetikett",
@@ -1507,6 +1517,7 @@
"option_used_in_logic_error": "Detta alternativ används i logiken för fråga {questionIndex}. Vänligen ta bort det från logiken först.",
"optional": "Valfritt",
"options": "Alternativ",
"options_used_in_logic_bulk_error": "Följande alternativ används i logiken: {questionIndexes}. Vänligen ta bort dem från logiken först.",
"override_theme_with_individual_styles_for_this_survey": "Åsidosätt temat med individuella stilar för denna enkät.",
"overwrite_global_waiting_time": "Ställ in anpassad väntetid",
"overwrite_global_waiting_time_description": "Åsidosätt projektkonfigurationen endast för denna enkät.",
@@ -1656,6 +1667,7 @@
"unsaved_changes_warning": "Du har osparade ändringar i din enkät. Vill du spara dem innan du lämnar?",
"until_they_submit_a_response": "Fråga tills de skickar in ett svar",
"untitled_block": "Namnlöst block",
"update_options": "Uppdatera alternativ",
"upgrade_notice_description": "Skapa flerspråkiga enkäter och lås upp många fler funktioner",
"upgrade_notice_title": "Lås upp flerspråkiga enkäter med en högre plan",
"upload": "Ladda upp",

View File

@@ -261,6 +261,7 @@
"maximum": "最大值",
"member": "成员",
"members": "成员",
"members_and_teams": "成员和团队",
"membership_not_found": "未找到会员资格",
"metadata": "元数据",
"minimum": "最低",
@@ -340,6 +341,7 @@
"read_docs": "阅读 文档",
"recipients": "收件人",
"remove": "移除",
"remove_from_team": "从团队中移除",
"reorder_and_hide_columns": "重新排序和隐藏列",
"report_survey": "报告调查",
"request_pricing": "请求 定价",
@@ -407,7 +409,8 @@
"team_access": "团队 访问",
"team_id": "团队 ID",
"team_name": "团队 名称",
"teams": "访问控制",
"team_role": "团队角色",
"teams": "团队",
"teams_not_found": "未找到 团队",
"text": "文本",
"time": "时间",
@@ -903,7 +906,6 @@
"teams": {
"manage_teams": "管理 团队",
"no_teams_found": "未找到 团队",
"only_organization_owners_and_managers_can_manage_teams": "只有 组织 拥有者 和 经理 可以 管理 团队。",
"permission": "权限",
"team_name": "团队名称",
"team_settings_description": "查看 哪些 团队 可以 访问 该 项目。"
@@ -1167,13 +1169,17 @@
"manage_team": "管理团队",
"manage_team_disabled": "只有 组织 拥有者、经理 和 团队 管理员 可以 管理 团队。",
"manager_role_description": "经理 可以 访问 所有 项目 并 添加 移除 成员。",
"member": "成员",
"member_role_description": "成员 可以 在 选定 项目 中 工作。",
"member_role_info_message": "要 给 新 成员 访问 项目 ,请 将 他们 添加 到 下方 的 团队 。通过 团队 你 可以 管理 谁 可以 访问 哪个 项目 。",
"organization_role": "组织角色",
"owner_role_description": "所有者拥有对组织的完全控制权。",
"please_fill_all_member_fields": "请 填写 所有 字段 以 添加 新 成员。",
"please_fill_all_project_fields": "请 填写 所有 字段 以 添加 新 项目。",
"read": "阅读",
"read_write": "读 & 写",
"select_member": "选择成员",
"select_project": "选择项目",
"team_admin": "团队管理员",
"team_created_successfully": "团队 创建 成功",
"team_deleted_successfully": "团队 删除 成功",
@@ -1261,6 +1267,10 @@
"bold": "粗体",
"brand_color": "品牌 颜色",
"brightness": "亮度",
"bulk_edit": "批量编辑",
"bulk_edit_description": "编辑以下所有选项,每行一个。空行将被跳过,重复项将被移除。",
"bulk_edit_options": "批量编辑选项",
"bulk_edit_options_for": "为 {language} 批量编辑选项",
"button_external": "启用外部链接",
"button_external_description": "添加一个按钮在新标签页中打开外部URL",
"button_label": "按钮标签",
@@ -1507,6 +1517,7 @@
"option_used_in_logic_error": "\"这个 选项 在 问题 {questionIndex} 的 逻辑 中 使用。请 先 从 逻辑 中 删除 它。\"",
"optional": "可选",
"options": "选项",
"options_used_in_logic_bulk_error": "以下选项在逻辑中被使用:{questionIndexes}。请先从逻辑中删除它们。",
"override_theme_with_individual_styles_for_this_survey": "使用 个性化 样式 替代 这份 问卷 的 主题。",
"overwrite_global_waiting_time": "设置自定义等待时间",
"overwrite_global_waiting_time_description": "仅为此调查覆盖项目配置。",
@@ -1656,6 +1667,7 @@
"unsaved_changes_warning": "您在调查中有未保存的更改。离开前是否要保存?",
"until_they_submit_a_response": "持续显示直到提交回应",
"untitled_block": "未命名区块",
"update_options": "更新选项",
"upgrade_notice_description": "创建 多语言 调查 并 解锁 更多 功能",
"upgrade_notice_title": "解锁 更高 计划 中 的 多语言 调查",
"upload": "上传",

View File

@@ -261,6 +261,7 @@
"maximum": "最大值",
"member": "成員",
"members": "成員",
"members_and_teams": "成員與團隊",
"membership_not_found": "找不到成員資格",
"metadata": "元數據",
"minimum": "最小值",
@@ -340,6 +341,7 @@
"read_docs": "閱讀文件",
"recipients": "收件者",
"remove": "移除",
"remove_from_team": "從團隊中移除",
"reorder_and_hide_columns": "重新排序和隱藏欄位",
"report_survey": "報告問卷",
"request_pricing": "請求定價",
@@ -407,7 +409,8 @@
"team_access": "團隊存取權限",
"team_id": "團隊 ID",
"team_name": "團隊名稱",
"teams": "存取控制",
"team_role": "團隊角色",
"teams": "團隊",
"teams_not_found": "找不到團隊",
"text": "文字",
"time": "時間",
@@ -903,7 +906,6 @@
"teams": {
"manage_teams": "管理團隊",
"no_teams_found": "找不到團隊",
"only_organization_owners_and_managers_can_manage_teams": "只有組織擁有者和管理員才能管理團隊。",
"permission": "權限",
"team_name": "團隊名稱",
"team_settings_description": "查看哪些團隊可以存取此專案。"
@@ -1167,13 +1169,17 @@
"manage_team": "管理團隊",
"manage_team_disabled": "只有組織擁有者、管理員和團隊管理員才能管理團隊。",
"manager_role_description": "管理員可以存取所有專案,並新增和移除成員。",
"member": "成員",
"member_role_description": "成員可以在選定的專案中工作。",
"member_role_info_message": "若要授予新成員存取專案的權限,請將他們新增至下方的團隊。藉由團隊,您可以管理誰可以存取哪些專案。",
"organization_role": "組織角色",
"owner_role_description": "擁有者對組織具有完全控制權。",
"please_fill_all_member_fields": "請填寫所有欄位以新增新成員。",
"please_fill_all_project_fields": "請填寫所有欄位以新增新專案。",
"read": "讀取",
"read_write": "讀取和寫入",
"select_member": "選擇成員",
"select_project": "選擇專案",
"team_admin": "團隊管理員",
"team_created_successfully": "團隊已成功建立。",
"team_deleted_successfully": "團隊已成功刪除。",
@@ -1261,6 +1267,10 @@
"bold": "粗體",
"brand_color": "品牌顏色",
"brightness": "亮度",
"bulk_edit": "批次編輯",
"bulk_edit_description": "在下方逐行編輯所有選項。空白行將被略過,重複項目將被移除。",
"bulk_edit_options": "批次編輯選項",
"bulk_edit_options_for": "為 {language} 批次編輯選項",
"button_external": "啟用外部連結",
"button_external_description": "新增一個按鈕,在新分頁中開啟外部網址",
"button_label": "按鈕標籤",
@@ -1507,6 +1517,7 @@
"option_used_in_logic_error": "此選項用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。",
"optional": "選填",
"options": "選項",
"options_used_in_logic_bulk_error": "以下選項已用於邏輯中:{questionIndexes}。請先從邏輯中移除它們。",
"override_theme_with_individual_styles_for_this_survey": "使用此問卷的個別樣式覆寫主題。",
"overwrite_global_waiting_time": "設定自訂等待時間",
"overwrite_global_waiting_time_description": "僅覆蓋此問卷的專案設定。",
@@ -1656,6 +1667,7 @@
"unsaved_changes_warning": "您的問卷中有未儲存的變更。您要先儲存它們再離開嗎?",
"until_they_submit_a_response": "持續詢問直到提交回應",
"untitled_block": "未命名區塊",
"update_options": "更新選項",
"upgrade_notice_description": "建立多語言問卷並解鎖更多功能",
"upgrade_notice_title": "使用更高等級的方案解鎖多語言問卷",
"upload": "上傳",

View File

@@ -3,7 +3,7 @@ import { OrganizationRole } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { CreateMembershipInvite } from "@/modules/auth/signup/types/invites";
import { createTeamMembership } from "../team";
import { createTeamMembership, getTeamProjectIds } from "../team";
// Setup all mocks
const setupMocks = () => {
@@ -31,6 +31,7 @@ const setupMocks = () => {
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
warn: vi.fn(),
},
}));
@@ -55,7 +56,7 @@ describe("Team Management", () => {
describe("createTeamMembership", () => {
describe("when user is an admin", () => {
test("creates a team membership with admin role", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM);
vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM as unknown as any);
vi.mocked(prisma.teamUser.create).mockResolvedValue(MOCK_TEAM_USER);
await createTeamMembership(MOCK_INVITE, MOCK_IDS.userId);
@@ -90,7 +91,7 @@ describe("Team Management", () => {
role: "member" as OrganizationRole,
};
vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM);
vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM as unknown as any);
vi.mocked(prisma.teamUser.create).mockResolvedValue({
...MOCK_TEAM_USER,
role: "contributor",
@@ -110,11 +111,68 @@ describe("Team Management", () => {
describe("error handling", () => {
test("throws error when database operation fails", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM);
vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM as unknown as any);
vi.mocked(prisma.teamUser.create).mockRejectedValue(new Error("Database error"));
await expect(createTeamMembership(MOCK_INVITE, MOCK_IDS.userId)).rejects.toThrow("Database error");
});
});
describe("when team does not exist", () => {
test("skips membership creation and continues to next team", async () => {
const inviteWithMultipleTeams: CreateMembershipInvite = {
...MOCK_INVITE,
teamIds: ["non-existent-team", MOCK_IDS.teamId],
};
vi.mocked(prisma.team.findUnique)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(MOCK_TEAM as unknown as any);
vi.mocked(prisma.teamUser.create).mockResolvedValue(MOCK_TEAM_USER);
await createTeamMembership(inviteWithMultipleTeams, MOCK_IDS.userId);
expect(prisma.team.findUnique).toHaveBeenCalledTimes(2);
expect(prisma.teamUser.create).toHaveBeenCalledTimes(1);
expect(prisma.teamUser.create).toHaveBeenCalledWith({
data: {
teamId: MOCK_IDS.teamId,
userId: MOCK_IDS.userId,
role: "admin",
},
});
});
});
});
describe("getTeamProjectIds", () => {
test("returns team with projectTeams when team exists", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM as unknown as any);
const result = await getTeamProjectIds(MOCK_IDS.teamId, MOCK_IDS.organizationId);
expect(result).toEqual(MOCK_TEAM);
expect(prisma.team.findUnique).toHaveBeenCalledWith({
where: {
id: MOCK_IDS.teamId,
organizationId: MOCK_IDS.organizationId,
},
select: {
projectTeams: {
select: {
projectId: true,
},
},
},
});
});
test("returns null when team does not exist", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValue(null);
const result = await getTeamProjectIds(MOCK_IDS.teamId, MOCK_IDS.organizationId);
expect(result).toBeNull();
});
});
});

View File

@@ -18,15 +18,18 @@ export const createTeamMembership = async (invite: CreateMembershipInvite, userI
for (const teamId of teamIds) {
const team = await getTeamProjectIds(teamId, invite.organizationId);
if (team) {
await prisma.teamUser.create({
data: {
teamId,
userId,
role: isOwnerOrManager ? "admin" : "contributor",
},
});
if (!team) {
logger.warn({ teamId, userId }, "Team no longer exists during invite acceptance");
continue;
}
await prisma.teamUser.create({
data: {
teamId,
userId,
role: isOwnerOrManager ? "admin" : "contributor",
},
});
}
} catch (error) {
logger.error(error, `Error creating team membership ${invite.organizationId} ${userId}`);
@@ -39,7 +42,10 @@ export const createTeamMembership = async (invite: CreateMembershipInvite, userI
};
export const getTeamProjectIds = reactCache(
async (teamId: string, organizationId: string): Promise<{ projectTeams: { projectId: string }[] }> => {
async (
teamId: string,
organizationId: string
): Promise<{ projectTeams: { projectId: string }[] } | null> => {
const team = await prisma.team.findUnique({
where: {
id: teamId,
@@ -55,7 +61,7 @@ export const getTeamProjectIds = reactCache(
});
if (!team) {
throw new Error("Team not found");
return null;
}
return team;

View File

@@ -458,21 +458,15 @@ describe("Contacts Lib", () => {
attributes: [{ attributeKey: { key: "email", id: "key-1" }, value: "john@example.com" }],
};
vi.mocked(prisma.contact.findMany)
.mockResolvedValueOnce([existingContact as any])
.mockResolvedValueOnce([{ key: "email", id: "key-1" } as any])
.mockResolvedValueOnce([
{ key: "userId", id: "key-2" },
{ key: "email", id: "key-1" },
] as any);
vi.mocked(prisma.contact.findMany).mockResolvedValueOnce([existingContact as any]);
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]);
vi.mocked(prisma.contactAttributeKey.findMany)
.mockResolvedValueOnce([{ key: "email", id: "key-1" }] as any)
.mockResolvedValueOnce([
{ key: "email", id: "key-1" },
{ key: "userId", id: "key-2" },
{ key: "name", id: "key-3" },
] as any);
vi.mocked(prisma.contactAttributeKey.createMany).mockResolvedValue({ count: 2 });
const result = await createContactsFromCSV(csvData, mockEnvironmentId, "skip", attributeMap);
@@ -489,25 +483,15 @@ describe("Contacts Lib", () => {
],
};
vi.mocked(prisma.contact.findMany)
.mockResolvedValueOnce([existingContact as any])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{ key: "email", id: "key-1" },
{ key: "userId", id: "key-2" },
] as any);
vi.mocked(prisma.contact.findMany).mockResolvedValueOnce([existingContact as any]);
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]);
vi.mocked(prisma.contactAttributeKey.findMany)
.mockResolvedValueOnce([
{ key: "email", id: "key-1" },
{ key: "userId", id: "key-2" },
] as any)
.mockResolvedValueOnce([
{ key: "email", id: "key-1" },
{ key: "userId", id: "key-2" },
] as any);
.mockResolvedValueOnce([{ key: "name", id: "key-3" }] as any);
vi.mocked(prisma.contactAttributeKey.createMany).mockResolvedValue({ count: 1 });
vi.mocked(prisma.contact.update).mockResolvedValue(existingContact as any);
const result = await createContactsFromCSV(csvData, mockEnvironmentId, "update", attributeMap);
@@ -525,25 +509,15 @@ describe("Contacts Lib", () => {
],
};
vi.mocked(prisma.contact.findMany)
.mockResolvedValueOnce([existingContact as any])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{ key: "email", id: "key-1" },
{ key: "userId", id: "key-2" },
] as any);
vi.mocked(prisma.contact.findMany).mockResolvedValueOnce([existingContact as any]);
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]);
vi.mocked(prisma.contactAttributeKey.findMany)
.mockResolvedValueOnce([
{ key: "email", id: "key-1" },
{ key: "userId", id: "key-2" },
] as any)
.mockResolvedValueOnce([
{ key: "email", id: "key-1" },
{ key: "userId", id: "key-2" },
] as any);
.mockResolvedValueOnce([{ key: "name", id: "key-3" }] as any);
vi.mocked(prisma.contactAttributeKey.createMany).mockResolvedValue({ count: 1 });
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 2 });
vi.mocked(prisma.contact.update).mockResolvedValue(existingContact as any);
@@ -582,23 +556,16 @@ describe("Contacts Lib", () => {
test("creates missing attribute keys", async () => {
const attributeMap = { email: "email", userId: "userId" };
vi.mocked(prisma.contact.findMany)
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{ key: "email", id: "key-1" },
{ key: "userId", id: "key-2" },
] as any);
vi.mocked(prisma.contact.findMany).mockResolvedValueOnce([]);
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]);
vi.mocked(prisma.contactAttributeKey.findMany)
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{ key: "email", id: "key-1" },
{ key: "userId", id: "key-2" },
{ key: "name", id: "key-3" },
] as any);
vi.mocked(prisma.contactAttributeKey.createMany).mockResolvedValue({ count: 2 });
vi.mocked(prisma.contactAttributeKey.createMany).mockResolvedValue({ count: 3 });
vi.mocked(prisma.contact.create).mockResolvedValue({
id: "new-1",
environmentId: mockEnvironmentId,

View File

@@ -200,6 +200,50 @@ export const deleteContact = async (contactId: string): Promise<TContact | null>
}
};
// Shared include clause for contact queries
const contactAttributesInclude = {
attributes: {
select: {
attributeKey: { select: { key: true } },
value: true,
},
},
} satisfies Prisma.ContactInclude;
// Helper to create attribute objects for Prisma create operations
const createAttributeConnections = (record: Record<string, string>, environmentId: string) =>
Object.entries(record).map(([key, value]) => ({
attributeKey: {
connect: { key_environmentId: { key, environmentId } },
},
value,
}));
// Helper to handle userId conflicts when updating/overwriting contacts
const resolveUserIdConflict = (
mappedRecord: Record<string, string>,
existingContact: { id: string; attributes: { attributeKey: { key: string }; value: string }[] },
existingUserIds: { value: string; contactId: string }[]
): Record<string, string> => {
const existingUserId = existingUserIds.find(
(attr) => attr.value === mappedRecord.userId && attr.contactId !== existingContact.id
);
if (!existingUserId) {
return { ...mappedRecord };
}
const { userId: _userId, ...rest } = mappedRecord;
const existingContactUserId = existingContact.attributes.find(
(attr) => attr.attributeKey.key === "userId"
)?.value;
return {
...rest,
...(existingContactUserId && { userId: existingContactUserId }),
};
};
export const createContactsFromCSV = async (
csvData: Record<string, string>[],
environmentId: string,
@@ -287,22 +331,36 @@ export const createContactsFromCSV = async (
});
const attributeKeyMap = new Map<string, string>();
// Map from lowercase key to actual DB key (for case-insensitive lookup)
const lowercaseToActualKeyMap = new Map<string, string>();
existingAttributeKeys.forEach((attrKey) => {
attributeKeyMap.set(attrKey.key, attrKey.id);
lowercaseToActualKeyMap.set(attrKey.key.toLowerCase(), attrKey.key);
});
// Identify missing attribute keys (normalize keys to lowercase)
// Collect all unique CSV keys
const csvKeys = new Set<string>();
csvData.forEach((record) => {
Object.keys(record).forEach((key) => csvKeys.add(key.toLowerCase()));
Object.keys(record).forEach((key) => csvKeys.add(key));
});
const missingKeys = Array.from(csvKeys).filter((key) => !attributeKeyMap.has(key));
// Identify missing attribute keys (case-insensitive check)
const missingKeys = Array.from(csvKeys).filter((key) => !lowercaseToActualKeyMap.has(key.toLowerCase()));
// Create missing attribute keys
// Create missing attribute keys (use original CSV casing for new keys)
if (missingKeys.length > 0) {
// Deduplicate by lowercase to avoid creating duplicates like "firstName" and "firstname"
const uniqueMissingKeys = new Map<string, string>();
missingKeys.forEach((key) => {
const lowerKey = key.toLowerCase();
if (!uniqueMissingKeys.has(lowerKey)) {
uniqueMissingKeys.set(lowerKey, key);
}
});
await prisma.contactAttributeKey.createMany({
data: missingKeys.map((key) => ({
data: Array.from(uniqueMissingKeys.values()).map((key) => ({
key,
name: key,
environmentId,
@@ -310,10 +368,10 @@ export const createContactsFromCSV = async (
skipDuplicates: true,
});
// Fetch and update the attributeKeyMap with new keys
// Fetch and update the maps with new keys
const newAttributeKeys = await prisma.contactAttributeKey.findMany({
where: {
key: { in: missingKeys },
key: { in: Array.from(uniqueMissingKeys.values()) },
environmentId,
},
select: { key: true, id: true },
@@ -321,6 +379,7 @@ export const createContactsFromCSV = async (
newAttributeKeys.forEach((attrKey) => {
attributeKeyMap.set(attrKey.key, attrKey.id);
lowercaseToActualKeyMap.set(attrKey.key.toLowerCase(), attrKey.key);
});
}
@@ -328,18 +387,23 @@ export const createContactsFromCSV = async (
// Process contacts in parallel
const contactPromises = csvData.map(async (record) => {
// Normalize record keys to lowercase
const normalizedRecord: Record<string, string> = {};
// Map CSV keys to actual DB keys (case-insensitive matching, preserving DB key casing)
const mappedRecord: Record<string, string> = {};
Object.entries(record).forEach(([key, value]) => {
normalizedRecord[key.toLowerCase()] = value;
const actualKey = lowercaseToActualKeyMap.get(key.toLowerCase());
if (!actualKey) {
// This should never happen since we create missing keys above
throw new ValidationError(`Attribute key "${key}" not found in attribute key map`);
}
mappedRecord[actualKey] = value;
});
// Skip records without email
if (!normalizedRecord.email) {
if (!mappedRecord.email) {
throw new ValidationError("Email is required for all contacts");
}
const existingContact = emailToContactMap.get(normalizedRecord.email);
const existingContact = emailToContactMap.get(mappedRecord.email);
if (existingContact) {
// Handle duplicates based on duplicateContactsAction
@@ -348,25 +412,7 @@ export const createContactsFromCSV = async (
return null;
case "update": {
// if the record has a userId, check if it already exists
const existingUserId = existingUserIds.find(
(attr) => attr.value === normalizedRecord.userid && attr.contactId !== existingContact.id
);
let recordToProcess = { ...normalizedRecord };
if (existingUserId) {
const { userid, ...rest } = recordToProcess;
const existingContactUserId = existingContact.attributes.find(
(attr) => attr.attributeKey.key === "userId"
)?.value;
recordToProcess = {
...rest,
...(existingContactUserId && {
userId: existingContactUserId,
}),
};
}
const recordToProcess = resolveUserIdConflict(mappedRecord, existingContact, existingUserIds);
const attributesToUpsert = Object.entries(recordToProcess).map(([key, value]) => ({
where: {
@@ -383,7 +429,7 @@ export const createContactsFromCSV = async (
}));
// Update contact with upserted attributes
const updatedContact = prisma.contact.update({
return prisma.contact.update({
where: { id: existingContact.id },
data: {
attributes: {
@@ -391,98 +437,40 @@ export const createContactsFromCSV = async (
upsert: attributesToUpsert,
},
},
include: {
attributes: {
select: {
attributeKey: { select: { key: true } },
value: true,
},
},
},
include: contactAttributesInclude,
});
return updatedContact;
}
case "overwrite": {
// if the record has a userId, check if it already exists
const existingUserId = existingUserIds.find(
(attr) => attr.value === normalizedRecord.userid && attr.contactId !== existingContact.id
);
let recordToProcess = { ...normalizedRecord };
if (existingUserId) {
const { userid, ...rest } = recordToProcess;
const existingContactUserId = existingContact.attributes.find(
(attr) => attr.attributeKey.key === "userId"
)?.value;
recordToProcess = {
...rest,
...(existingContactUserId && {
userId: existingContactUserId,
}),
};
}
const recordToProcess = resolveUserIdConflict(mappedRecord, existingContact, existingUserIds);
// Overwrite by deleting existing attributes and creating new ones
await prisma.contactAttribute.deleteMany({
where: { contactId: existingContact.id },
});
const newAttributes = Object.entries(recordToProcess).map(([key, value]) => ({
attributeKey: {
connect: { key_environmentId: { key, environmentId } },
},
value,
}));
const updatedContact = prisma.contact.update({
return prisma.contact.update({
where: { id: existingContact.id },
data: {
attributes: {
create: newAttributes,
},
},
include: {
attributes: {
select: {
attributeKey: { select: { key: true } },
value: true,
},
create: createAttributeConnections(recordToProcess, environmentId),
},
},
include: contactAttributesInclude,
});
return updatedContact;
}
}
} else {
// Create new contact
const newAttributes = Object.entries(record).map(([key, value]) => ({
attributeKey: {
connect: { key_environmentId: { key, environmentId } },
},
value,
}));
const newContact = prisma.contact.create({
// Create new contact - use mappedRecord with proper DB key casing
return prisma.contact.create({
data: {
environmentId,
attributes: {
create: newAttributes,
},
},
include: {
attributes: {
select: {
attributeKey: { select: { key: true } },
value: true,
},
create: createAttributeConnections(mappedRecord, environmentId),
},
},
include: contactAttributesInclude,
});
return newContact;
}
});

View File

@@ -7,6 +7,7 @@ import { createCacheKey } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { cache } from "@/lib/cache";
import { E2E_TESTING } from "@/lib/constants";
import { env } from "@/lib/env";
import { hashString } from "@/lib/hash-string";
import { getInstanceId } from "@/lib/instance";
@@ -262,7 +263,9 @@ const fetchLicenseFromServerInternal = async (retryCount = 0): Promise<TEnterpri
const startOfNextYear = new Date(now.getFullYear() + 1, 0, 1);
const [instanceId, responseCount] = await Promise.all([
getInstanceId(),
// Skip instance ID during E2E tests to avoid license key conflicts
// as the instance ID changes with each test run
E2E_TESTING ? null : getInstanceId(),
prisma.response.count({
where: {
createdAt: {
@@ -274,7 +277,8 @@ const fetchLicenseFromServerInternal = async (retryCount = 0): Promise<TEnterpri
]);
// No organization exists, cannot perform license check
if (!instanceId) return null;
// (skip this check during E2E tests as we intentionally use null)
if (!E2E_TESTING && !instanceId) return null;
const proxyUrl = env.HTTPS_PROXY ?? env.HTTP_PROXY;
const agent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined;
@@ -282,12 +286,17 @@ const fetchLicenseFromServerInternal = async (retryCount = 0): Promise<TEnterpri
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), CONFIG.API.TIMEOUT_MS);
const payload: Record<string, unknown> = {
licenseKey: env.ENTERPRISE_LICENSE_KEY,
usage: { responseCount },
};
if (instanceId) {
payload.instanceId = instanceId;
}
const res = await fetch(CONFIG.API.ENDPOINT, {
body: JSON.stringify({
licenseKey: env.ENTERPRISE_LICENSE_KEY,
usage: { responseCount },
instanceId,
}),
body: JSON.stringify(payload),
headers: { "Content-Type": "application/json" },
method: "POST",
agent,

View File

@@ -60,7 +60,7 @@ export function AddMemberRole({
name="role"
render={({ field: { onChange, value } }) => (
<div className="flex flex-col space-y-2">
<Label>{t("common.role_organization")}</Label>
<Label>{t("environments.settings.teams.organization_role")}</Label>
<Select
defaultValue={isAccessControlAllowed ? "member" : "owner"}
disabled={!isAccessControlAllowed}

View File

@@ -4,12 +4,12 @@ import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError, UnknownError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import { getProjectPermissionByUserId, getTeamRoleByTeamIdUserId } from "./roles";
import { getProjectPermissionByUserId, getTeamRoleByTeamIdUserId, getTeamsWhereUserIsAdmin } from "./roles";
vi.mock("@formbricks/database", () => ({
prisma: {
projectTeam: { findMany: vi.fn() },
teamUser: { findUnique: vi.fn() },
teamUser: { findUnique: vi.fn(), findMany: vi.fn() },
},
}));
@@ -19,6 +19,7 @@ vi.mock("@/lib/utils/validate", () => ({ validateInputs: vi.fn() }));
const mockUserId = "user-1";
const mockProjectId = "project-1";
const mockTeamId = "team-1";
const mockOrganizationId = "org-1";
describe("roles lib", () => {
beforeEach(() => {
@@ -90,7 +91,7 @@ describe("roles lib", () => {
});
test("returns role if teamUser exists", async () => {
vi.mocked(prisma.teamUser.findUnique).mockResolvedValueOnce({ role: "member" });
vi.mocked(prisma.teamUser.findUnique).mockResolvedValueOnce({ role: "member" } as unknown as any);
const result = await getTeamRoleByTeamIdUserId(mockTeamId, mockUserId);
expect(result).toBe("member");
});
@@ -110,4 +111,47 @@ describe("roles lib", () => {
await expect(getTeamRoleByTeamIdUserId(mockTeamId, mockUserId)).rejects.toThrow(error);
});
});
describe("getTeamsWhereUserIsAdmin", () => {
test("returns empty array if user is not admin of any team", async () => {
vi.mocked(prisma.teamUser.findMany).mockResolvedValueOnce([]);
const result = await getTeamsWhereUserIsAdmin(mockUserId, mockOrganizationId);
expect(result).toEqual([]);
expect(validateInputs).toHaveBeenCalledWith(
[mockUserId, expect.anything()],
[mockOrganizationId, expect.anything()]
);
});
test("returns array of team IDs where user is admin", async () => {
vi.mocked(prisma.teamUser.findMany).mockResolvedValueOnce([
{ teamId: "team-1" },
{ teamId: "team-2" },
{ teamId: "team-3" },
] as unknown as any);
const result = await getTeamsWhereUserIsAdmin(mockUserId, mockOrganizationId);
expect(result).toEqual(["team-1", "team-2", "team-3"]);
});
test("returns single team ID when user is admin of one team", async () => {
vi.mocked(prisma.teamUser.findMany).mockResolvedValueOnce([{ teamId: "team-1" }] as unknown as any);
const result = await getTeamsWhereUserIsAdmin(mockUserId, mockOrganizationId);
expect(result).toEqual(["team-1"]);
});
test("throws DatabaseError on PrismaClientKnownRequestError", async () => {
const error = new Prisma.PrismaClientKnownRequestError("fail", {
code: "P2002",
clientVersion: "1.0.0",
});
vi.mocked(prisma.teamUser.findMany).mockRejectedValueOnce(error);
await expect(getTeamsWhereUserIsAdmin(mockUserId, mockOrganizationId)).rejects.toThrow(DatabaseError);
});
test("throws error on generic error", async () => {
const error = new Error("fail");
vi.mocked(prisma.teamUser.findMany).mockRejectedValueOnce(error);
await expect(getTeamsWhereUserIsAdmin(mockUserId, mockOrganizationId)).rejects.toThrow(error);
});
});
});

View File

@@ -83,3 +83,31 @@ export const getTeamRoleByTeamIdUserId = reactCache(
}
}
);
export const getTeamsWhereUserIsAdmin = reactCache(
async (userId: string, organizationId: string): Promise<string[]> => {
validateInputs([userId, ZId], [organizationId, ZId]);
try {
const adminTeams = await prisma.teamUser.findMany({
where: {
userId,
role: "admin",
team: {
organizationId,
},
},
select: {
teamId: true,
},
});
return adminTeams.map((at) => at.teamId);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
}
);

View File

@@ -43,7 +43,7 @@ export const AccessTable = ({ teams }: AccessTableProps) => {
{team.memberCount} {team.memberCount === 1 ? t("common.member") : t("common.members")}
</TableCell>
<TableCell>
<IdBadge id={team.id} showCopyIconOnHover={true} />
<IdBadge id={team.id} />
</TableCell>
<TableCell>
<p className="capitalize">{TeamPermissionMapping[team.permission]}</p>

View File

@@ -9,10 +9,9 @@ import { TProjectTeam } from "@/modules/ee/teams/project-teams/types/team";
interface AccessViewProps {
teams: TProjectTeam[];
environmentId: string;
isOwnerOrManager: boolean;
}
export const AccessView = ({ teams, environmentId, isOwnerOrManager }: AccessViewProps) => {
export const AccessView = ({ teams, environmentId }: AccessViewProps) => {
const { t } = useTranslation();
return (
<>
@@ -20,7 +19,7 @@ export const AccessView = ({ teams, environmentId, isOwnerOrManager }: AccessVie
title={t("common.team_access")}
description={t("environments.project.teams.team_settings_description")}>
<div className="mb-4 flex justify-end">
<ManageTeam environmentId={environmentId} isOwnerOrManager={isOwnerOrManager} />
<ManageTeam environmentId={environmentId} />
</div>
<AccessTable teams={teams} />
</SettingsCard>

View File

@@ -3,14 +3,12 @@
import { useRouter } from "next/navigation";
import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
interface ManageTeamProps {
environmentId: string;
isOwnerOrManager: boolean;
}
export const ManageTeam = ({ environmentId, isOwnerOrManager }: ManageTeamProps) => {
export const ManageTeam = ({ environmentId }: ManageTeamProps) => {
const { t } = useTranslation();
const router = useRouter();
@@ -19,20 +17,9 @@ export const ManageTeam = ({ environmentId, isOwnerOrManager }: ManageTeamProps)
router.push(`/environments/${environmentId}/settings/teams`);
};
if (isOwnerOrManager) {
return (
<Button variant="secondary" size="sm" onClick={handleManageTeams}>
{t("environments.project.teams.manage_teams")}
</Button>
);
}
return (
<TooltipRenderer
tooltipContent={t("environments.project.teams.only_organization_owners_and_managers_can_manage_teams")}>
<Button variant="secondary" size="sm" disabled>
{t("environments.project.teams.manage_teams")}
</Button>
</TooltipRenderer>
<Button variant="secondary" size="sm" onClick={handleManageTeams}>
{t("environments.project.teams.manage_teams")}
</Button>
);
};

View File

@@ -10,7 +10,7 @@ export const ProjectTeams = async (props: { params: Promise<{ environmentId: str
const t = await getTranslate();
const params = await props.params;
const { project, isOwner, isManager } = await getEnvironmentAuth(params.environmentId);
const { project } = await getEnvironmentAuth(params.environmentId);
const teams = await getTeamsByProjectId(project.id);
@@ -18,14 +18,12 @@ export const ProjectTeams = async (props: { params: Promise<{ environmentId: str
throw new Error(t("common.teams_not_found"));
}
const isOwnerOrManager = isOwner || isManager;
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.project_configuration")}>
<ProjectConfigNavigation environmentId={params.environmentId} activeId="teams" />
</PageHeader>
<AccessView environmentId={params.environmentId} teams={teams} isOwnerOrManager={isOwnerOrManager} />
<AccessView environmentId={params.environmentId} teams={teams} />
</PageContentWrapper>
);
};

View File

@@ -1,7 +1,7 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { PlusIcon, Trash2Icon } from "lucide-react";
import { PlusIcon, Trash2Icon, XIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useMemo } from "react";
import { FormProvider, SubmitHandler, useForm, useWatch } from "react-hook-form";
@@ -80,6 +80,16 @@ export const TeamSettingsModal = ({
const router = useRouter();
// Track initial member IDs to distinguish existing members from newly added ones
const initialMemberIds = useMemo(() => {
return new Set(team.members.map((member) => member.userId));
}, [team.members]);
// Track initial project IDs to distinguish existing projects from newly added ones
const initialProjectIds = useMemo(() => {
return new Set(team.projects.map((project) => project.projectId));
}, [team.projects]);
const initialMembers = useMemo(() => {
const members = team.members.map((member) => ({
userId: member.userId,
@@ -259,34 +269,44 @@ export const TeamSettingsModal = ({
<FormField
control={control}
name={`members.${index}.userId`}
render={({ field, fieldState: { error } }) => (
<FormItem className="flex-1">
<Select
onValueChange={(val) => {
field.onChange(val);
handleMemberSelectionChange(index, val);
}}
disabled={!isOwnerOrManager && !isTeamAdminMember}
value={member.userId}>
<SelectTrigger>
<SelectValue placeholder="Select member" />
</SelectTrigger>
<SelectContent>
{memberOpts.map((option) => (
<SelectItem
key={option.value}
value={option.value}
id={`member-${index}-option`}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{error?.message && (
<FormError className="text-left">{error.message}</FormError>
)}
</FormItem>
)}
render={({ field, fieldState: { error } }) => {
// Disable user select for existing members (can only remove or change role)
const isExistingMember =
member.userId && initialMemberIds.has(member.userId);
const isSelectDisabled =
isExistingMember || (!isOwnerOrManager && !isTeamAdminMember);
return (
<FormItem className="flex-1">
<Select
onValueChange={(val) => {
field.onChange(val);
handleMemberSelectionChange(index, val);
}}
disabled={isSelectDisabled}
value={member.userId}>
<SelectTrigger>
<SelectValue
placeholder={t("environments.settings.teams.select_member")}
/>
</SelectTrigger>
<SelectContent>
{memberOpts.map((option) => (
<SelectItem
key={option.value}
value={option.value}
id={`member-${index}-option`}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{error?.message && (
<FormError className="text-left">{error.message}</FormError>
)}
</FormItem>
);
}}
/>
<FormField
@@ -328,18 +348,20 @@ export const TeamSettingsModal = ({
{/* Delete Button for Member */}
{watchMembers.length > 1 && (
<Button
size="icon"
type="button"
variant="secondary"
className="shrink-0"
disabled={
!isOwnerOrManager &&
(!isTeamAdminMember || member.userId === currentUserId)
}
onClick={() => handleRemoveMember(index)}>
<Trash2Icon className="h-4 w-4" />
</Button>
<TooltipRenderer tooltipContent={t("common.remove_from_team")}>
<Button
size="icon"
type="button"
variant="destructive"
className="shrink-0"
disabled={
!isOwnerOrManager &&
(!isTeamAdminMember || member.userId === currentUserId)
}
onClick={() => handleRemoveMember(index)}>
<XIcon className="h-4 w-4" />
</Button>
</TooltipRenderer>
)}
</div>
);
@@ -360,7 +382,7 @@ export const TeamSettingsModal = ({
: t("environments.settings.teams.all_members_added")
}>
<Button
size="default"
size="sm"
type="button"
variant="secondary"
onClick={handleAddMember}
@@ -396,31 +418,40 @@ export const TeamSettingsModal = ({
<FormField
control={control}
name={`projects.${index}.projectId`}
render={({ field, fieldState: { error } }) => (
<FormItem className="flex-1">
<Select
onValueChange={field.onChange}
value={project.projectId}
disabled={!isOwnerOrManager}>
<SelectTrigger>
<SelectValue placeholder="Select project" />
</SelectTrigger>
<SelectContent>
{projectOpts.map((option) => (
<SelectItem
key={option.value}
value={option.value}
id={`project-${index}-option`}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{error?.message && (
<FormError className="text-left">{error.message}</FormError>
)}
</FormItem>
)}
render={({ field, fieldState: { error } }) => {
// Disable project select for existing projects (can only remove or change permission)
const isExistingProject =
project.projectId && initialProjectIds.has(project.projectId);
const isSelectDisabled = isExistingProject || !isOwnerOrManager;
return (
<FormItem className="flex-1">
<Select
onValueChange={field.onChange}
value={project.projectId}
disabled={isSelectDisabled}>
<SelectTrigger>
<SelectValue
placeholder={t("environments.settings.teams.select_project")}
/>
</SelectTrigger>
<SelectContent>
{projectOpts.map((option) => (
<SelectItem
key={option.value}
value={option.value}
id={`project-${index}-option`}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{error?.message && (
<FormError className="text-left">{error.message}</FormError>
)}
</FormItem>
);
}}
/>
<FormField
@@ -481,7 +512,7 @@ export const TeamSettingsModal = ({
: t("environments.settings.teams.all_projects_added")
}>
<Button
size="default"
size="sm"
type="button"
variant="secondary"
onClick={handleAddProject}

View File

@@ -4,7 +4,7 @@ import { OrganizationRole } from "@prisma/client";
import { z } from "zod";
import { ZId, ZUuid } from "@formbricks/types/common";
import { AuthenticationError, OperationNotAllowedError, ValidationError } from "@formbricks/types/errors";
import { ZOrganizationRole } from "@formbricks/types/memberships";
import { TOrganizationRole, ZOrganizationRole } from "@formbricks/types/memberships";
import { INVITE_DISABLED, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { createInviteToken } from "@/lib/jwt";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
@@ -16,6 +16,7 @@ import { getOrganizationIdFromInviteId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { checkRoleManagementPermission } from "@/modules/ee/role-management/actions";
import { getTeamsWhereUserIsAdmin } from "@/modules/ee/teams/lib/roles";
import { sendInviteMemberEmail } from "@/modules/email";
import {
deleteMembership,
@@ -195,19 +196,55 @@ export const resendInviteAction = authenticatedActionClient.schema(ZResendInvite
)
);
const validateTeamAdminInvitePermissions = (
inviterRole: TOrganizationRole,
inviterAdminTeams: string[],
inviteRole: TOrganizationRole,
inviteTeamIds: string[]
): void => {
const isOrgOwnerOrManager = inviterRole === "owner" || inviterRole === "manager";
const isTeamAdmin = inviterAdminTeams.length > 0;
if (!isOrgOwnerOrManager && !isTeamAdmin) {
throw new AuthenticationError("Only organization owners, managers, or team admins can invite members");
}
// Team admins have restrictions
if (isTeamAdmin && !isOrgOwnerOrManager) {
if (inviteRole !== "member") {
throw new OperationNotAllowedError("Team admins can only invite users as members");
}
const invalidTeams = inviteTeamIds.filter((id) => !inviterAdminTeams.includes(id));
if (invalidTeams.length > 0) {
throw new OperationNotAllowedError("Team admins can only add users to teams where they are admin");
}
if (inviteTeamIds.length === 0) {
throw new ValidationError("Team admins must add invited users to at least one team");
}
}
};
const ZInviteUserAction = z.object({
organizationId: ZId,
email: z.string(),
name: z.string(),
name: z.string().trim().min(1, "Name is required"),
role: ZOrganizationRole,
teamIds: z.array(z.string()),
teamIds: z.array(ZId),
});
export const inviteUserAction = authenticatedActionClient.schema(ZInviteUserAction).action(
withAuditLogging(
"created",
"invite",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZInviteUserAction>;
}) => {
if (INVITE_DISABLED) {
throw new AuthenticationError("Invite disabled");
}
@@ -224,16 +261,41 @@ export const inviteUserAction = authenticatedActionClient.schema(ZInviteUserActi
throw new AuthenticationError("User not a member of this organization");
}
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
],
});
const isOrgOwnerOrManager =
currentUserMembership.role === "owner" || currentUserMembership.role === "manager";
// Fetch user's admin teams (empty array if owner/manager to skip unnecessary query)
const userAdminTeams = isOrgOwnerOrManager
? []
: await getTeamsWhereUserIsAdmin(ctx.user.id, parsedInput.organizationId);
const isTeamAdmin = userAdminTeams.length > 0;
if (!isOrgOwnerOrManager && !isTeamAdmin) {
throw new AuthenticationError("Not authorized to invite members");
}
if (isOrgOwnerOrManager) {
// Standard org-level auth check
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
],
});
}
// Validate team admin restrictions
validateTeamAdminInvitePermissions(
currentUserMembership.role,
userAdminTeams,
parsedInput.role,
parsedInput.teamIds
);
if (currentUserMembership.role === "manager" && parsedInput.role !== "member") {
throw new OperationNotAllowedError("Managers can only invite users as members");

View File

@@ -37,6 +37,8 @@ interface OrganizationActionsProps {
isMultiOrgEnabled: boolean;
isUserManagementDisabledFromUi: boolean;
isStorageConfigured: boolean;
isTeamAdmin: boolean;
userAdminTeamIds?: string[];
}
export const OrganizationActions = ({
@@ -52,16 +54,20 @@ export const OrganizationActions = ({
isMultiOrgEnabled,
isUserManagementDisabledFromUi,
isStorageConfigured,
isTeamAdmin,
userAdminTeamIds,
}: OrganizationActionsProps) => {
const router = useRouter();
const { t } = useTranslation();
const [isLeaveOrganizationModalOpen, setLeaveOrganizationModalOpen] = useState(false);
const [isInviteMemberModalOpen, setInviteMemberModalOpen] = useState(false);
const [isLeaveOrganizationModalOpen, setIsLeaveOrganizationModalOpen] = useState(false);
const [isInviteMemberModalOpen, setIsInviteMemberModalOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { isOwner, isManager } = getAccessFlags(membershipRole);
const isOwnerOrManager = isOwner || isManager;
const canInvite = isOwnerOrManager || (isAccessControlAllowed && isTeamAdmin);
const handleLeaveOrganization = async () => {
setLoading(true);
try {
@@ -134,18 +140,18 @@ export const OrganizationActions = ({
<>
<div className="mb-4 flex justify-end space-x-2 text-right">
{role !== "owner" && isMultiOrgEnabled && (
<Button variant="secondary" size="sm" onClick={() => setLeaveOrganizationModalOpen(true)}>
<Button variant="destructive" size="sm" onClick={() => setIsLeaveOrganizationModalOpen(true)}>
{t("environments.settings.general.leave_organization")}
<XIcon />
</Button>
)}
{!isInviteDisabled && isOwnerOrManager && !isUserManagementDisabledFromUi && (
{!isInviteDisabled && canInvite && !isUserManagementDisabledFromUi && (
<Button
size="sm"
variant="secondary"
variant="default"
onClick={() => {
setInviteMemberModalOpen(true);
setIsInviteMemberModalOpen(true);
}}>
{t("environments.settings.teams.invite_member")}
</Button>
@@ -153,7 +159,7 @@ export const OrganizationActions = ({
</div>
<InviteMemberModal
open={isInviteMemberModalOpen}
setOpen={setInviteMemberModalOpen}
setOpen={setIsInviteMemberModalOpen}
onSubmit={handleAddMembers}
membershipRole={membershipRole}
isAccessControlAllowed={isAccessControlAllowed}
@@ -161,9 +167,12 @@ export const OrganizationActions = ({
environmentId={environmentId}
teams={teams}
isStorageConfigured={isStorageConfigured}
isOwnerOrManager={isOwnerOrManager}
isTeamAdmin={isTeamAdmin}
userAdminTeamIds={userAdminTeamIds}
/>
<Dialog open={isLeaveOrganizationModalOpen} onOpenChange={setLeaveOrganizationModalOpen}>
<Dialog open={isLeaveOrganizationModalOpen} onOpenChange={setIsLeaveOrganizationModalOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("environments.settings.general.leave_organization_title")}</DialogTitle>
@@ -177,7 +186,7 @@ export const OrganizationActions = ({
</p>
)}
<DialogFooter>
<Button variant="secondary" onClick={() => setLeaveOrganizationModalOpen(false)}>
<Button variant="secondary" onClick={() => setIsLeaveOrganizationModalOpen(false)}>
{t("common.cancel")}
</Button>
<Button

View File

@@ -7,13 +7,14 @@ import { useRouter } from "next/navigation";
import { FormProvider, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { TOrganizationRole, ZOrganizationRole } from "@formbricks/types/memberships";
import { ZUserName } from "@formbricks/types/user";
import { AddMemberRole } from "@/modules/ee/role-management/components/add-member-role";
import { TOrganizationTeam } from "@/modules/ee/teams/team-list/types/team";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
import { FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { MultiSelect } from "@/modules/ui/components/multi-select";
@@ -27,6 +28,7 @@ interface IndividualInviteTabProps {
isFormbricksCloud: boolean;
environmentId: string;
membershipRole?: TOrganizationRole;
showTeamAdminRestrictions: boolean;
}
export const IndividualInviteTab = ({
@@ -37,22 +39,32 @@ export const IndividualInviteTab = ({
isFormbricksCloud,
environmentId,
membershipRole,
showTeamAdminRestrictions,
}: IndividualInviteTabProps) => {
const ZFormSchema = z.object({
name: ZUserName,
email: z.string().min(1, { message: "Email is required" }).email({ message: "Invalid email" }),
role: ZOrganizationRole,
teamIds: z.array(z.string()),
teamIds: showTeamAdminRestrictions
? z.array(ZId).min(1, { message: "Team admins must select at least one team" })
: z.array(ZId),
});
const router = useRouter();
type TFormData = z.infer<typeof ZFormSchema>;
const { t } = useTranslation();
// Determine default role based on permissions
let defaultRole: TOrganizationRole = "owner";
if (showTeamAdminRestrictions || isAccessControlAllowed) {
defaultRole = "member";
}
const form = useForm<TFormData>({
resolver: zodResolver(ZFormSchema),
defaultValues: {
role: isAccessControlAllowed ? "member" : "owner",
role: defaultRole,
teamIds: [],
},
});
@@ -104,43 +116,61 @@ export const IndividualInviteTab = ({
{errors.email && <p className="mt-1 text-sm text-red-500">{errors.email.message}</p>}
</div>
<div>
<AddMemberRole
control={control}
isAccessControlAllowed={isAccessControlAllowed}
isFormbricksCloud={isFormbricksCloud}
membershipRole={membershipRole}
/>
{watch("role") === "member" && (
<Alert className="mt-2" variant="info">
<AlertDescription>{t("environments.settings.teams.member_role_info_message")}</AlertDescription>
</Alert>
{showTeamAdminRestrictions ? (
<div className="flex flex-col space-y-2">
<Label htmlFor="memberRoleSelect">{t("environments.settings.teams.organization_role")}</Label>
<Input value={t("environments.settings.teams.member")} disabled />
</div>
) : (
<>
<AddMemberRole
control={control}
isAccessControlAllowed={isAccessControlAllowed}
isFormbricksCloud={isFormbricksCloud}
membershipRole={membershipRole}
/>
{watch("role") === "member" && (
<Alert className="mt-2" variant="info">
<AlertDescription>
{t("environments.settings.teams.member_role_info_message")}
</AlertDescription>
</Alert>
)}
</>
)}
</div>
{isAccessControlAllowed && (
<FormField
control={control}
name="teamIds"
render={({ field }) => (
<FormItem className="flex flex-col space-y-2">
<FormLabel>{t("common.add_to_team")} </FormLabel>
<div className="space-y-2">
<MultiSelect
value={field.value}
options={teamOptions}
placeholder={t("environments.settings.teams.team_select_placeholder")}
disabled={!teamOptions.length}
onChange={(val) => field.onChange(val)}
/>
{!teamOptions.length && (
<Small className="font-normal text-amber-600">
{t("environments.settings.teams.create_first_team_message")}
</Small>
)}
</div>
</FormItem>
)}
/>
<>
<FormField
control={control}
name="teamIds"
render={({ field }) => (
<FormItem className="flex flex-col space-y-2">
<FormLabel>{t("common.add_to_team")} </FormLabel>
<div className="space-y-2">
<MultiSelect
value={field.value}
options={teamOptions}
placeholder={t("environments.settings.teams.team_select_placeholder")}
disabled={!teamOptions.length}
onChange={(val) => field.onChange(val)}
/>
{!teamOptions.length && (
<Small className="font-normal text-amber-600">
{t("environments.settings.teams.create_first_team_message")}
</Small>
)}
</div>
<FormError>{errors.teamIds?.message}</FormError>
</FormItem>
)}
/>
<div className="flex flex-col space-y-2">
<Label htmlFor="teamRoleInput">{t("common.team_role")}</Label>
<Input value={t("environments.settings.teams.contributor")} disabled />
</div>
</>
)}
{!isAccessControlAllowed && (

View File

@@ -26,6 +26,9 @@ interface InviteMemberModalProps {
environmentId: string;
membershipRole?: TOrganizationRole;
isStorageConfigured: boolean;
isOwnerOrManager: boolean;
isTeamAdmin: boolean;
userAdminTeamIds?: string[];
}
export const InviteMemberModal = ({
@@ -38,11 +41,21 @@ export const InviteMemberModal = ({
environmentId,
membershipRole,
isStorageConfigured,
isOwnerOrManager,
isTeamAdmin,
userAdminTeamIds,
}: InviteMemberModalProps) => {
const [type, setType] = useState<"individual" | "bulk">("individual");
const { t } = useTranslation();
const showTeamAdminRestrictions = !isOwnerOrManager && isTeamAdmin;
const filteredTeams =
showTeamAdminRestrictions && userAdminTeamIds
? teams.filter((t) => userAdminTeamIds.includes(t.id))
: teams;
const tabs = {
individual: (
<IndividualInviteTab
@@ -51,8 +64,9 @@ export const InviteMemberModal = ({
onSubmit={onSubmit}
isAccessControlAllowed={isAccessControlAllowed}
isFormbricksCloud={isFormbricksCloud}
teams={teams}
teams={filteredTeams}
membershipRole={membershipRole}
showTeamAdminRestrictions={showTeamAdminRestrictions}
/>
),
bulk: (
@@ -75,16 +89,18 @@ export const InviteMemberModal = ({
</DialogHeader>
<DialogBody className="flex flex-col gap-6" unconstrained>
<TabToggle
id="type"
options={[
{ value: "individual", label: t("environments.settings.teams.individual") },
{ value: "bulk", label: t("environments.settings.teams.bulk_invite") },
]}
onChange={(inviteType) => setType(inviteType)}
defaultSelected={type}
/>
{tabs[type]}
{!showTeamAdminRestrictions && (
<TabToggle
id="type"
options={[
{ value: "individual", label: t("environments.settings.teams.individual") },
{ value: "bulk", label: t("environments.settings.teams.bulk_invite") },
]}
onChange={(inviteType) => setType(inviteType)}
defaultSelected={type}
/>
)}
{showTeamAdminRestrictions ? tabs.individual : tabs[type]}
</DialogBody>
</DialogContent>
</Dialog>

View File

@@ -5,6 +5,7 @@ import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/
import { INVITE_DISABLED, IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { getTeamsWhereUserIsAdmin } from "@/modules/ee/teams/lib/roles";
import { getTeamsByOrganizationId } from "@/modules/ee/teams/team-list/lib/team";
import { TOrganizationTeam } from "@/modules/ee/teams/team-list/types/team";
import { EditMemberships } from "@/modules/organization/settings/teams/components/edit-memberships";
@@ -45,6 +46,10 @@ export const MembersView = async ({
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
// Fetch admin teams if they're a team admin
const userAdminTeamIds = await getTeamsWhereUserIsAdmin(currentUserId, organization.id);
const isTeamAdminUser = userAdminTeamIds.length > 0;
let teams: TOrganizationTeam[] = [];
if (isAccessControlAllowed) {
@@ -69,6 +74,8 @@ export const MembersView = async ({
isMultiOrgEnabled={isMultiOrgEnabled}
teams={teams}
isUserManagementDisabledFromUi={isUserManagementDisabledFromUi}
isTeamAdmin={isTeamAdminUser}
userAdminTeamIds={userAdminTeamIds}
/>
)}

View File

@@ -3,6 +3,7 @@ import { IS_FORMBRICKS_CLOUD, USER_MANAGEMENT_MINIMUM_ROLE } from "@/lib/constan
import { getUserManagementAccess } from "@/lib/membership/utils";
import { getTranslate } from "@/lingodotdev/server";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
import { getTeamsWhereUserIsAdmin } from "@/modules/ee/teams/lib/roles";
import { TeamsView } from "@/modules/ee/teams/team-list/components/teams-view";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { MembersView } from "@/modules/organization/settings/teams/components/members-view";
@@ -16,11 +17,21 @@ export const TeamsPage = async (props) => {
const { session, currentUserMembership, organization } = await getEnvironmentAuth(params.environmentId);
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
const hasUserManagementAccess = getUserManagementAccess(
// Check if user has standard user management access (owner/manager)
const hasStandardUserManagementAccess = getUserManagementAccess(
currentUserMembership?.role,
USER_MANAGEMENT_MINIMUM_ROLE
);
// Also check if user is a team admin (they get limited user management for invites)
const userAdminTeamIds = await getTeamsWhereUserIsAdmin(session.user.id, organization.id);
const isTeamAdminUser = userAdminTeamIds.length > 0;
// Allow user management UI if they're owner/manager OR team admin (when access control is enabled)
const hasUserManagementAccess =
hasStandardUserManagementAccess || (isAccessControlAllowed && isTeamAdminUser);
return (
<PageContentWrapper>
<PageHeader pageTitle={t("environments.settings.general.organization_settings")}>

View File

@@ -0,0 +1,195 @@
"use client";
import { createId } from "@paralleldrive/cuid2";
import { type JSX, useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
import { TI18nString } from "@formbricks/types/i18n";
import { TSurveyMultipleChoiceElement } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { createI18nString } from "@/lib/i18n/utils";
import { findElementLocation } from "@/modules/survey/editor/lib/blocks";
import { findOptionUsedInLogic } from "@/modules/survey/editor/lib/utils";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
interface BulkEditOptionsModalProps {
isOpen: boolean;
onClose: () => void;
regularChoices: TSurveyMultipleChoiceElement["choices"];
onSave: (updatedChoices: TSurveyMultipleChoiceElement["choices"]) => void;
element: TSurveyMultipleChoiceElement;
localSurvey: TSurvey;
selectedLanguageCode: string;
surveyLanguageCodes: string[];
locale: TUserLocale;
}
const parseUniqueLines = (content: string): string[] => {
return [
...new Set(
content
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
),
];
};
const updateChoiceLabel = (
choice: TSurveyMultipleChoiceElement["choices"][number],
newLabel: string,
selectedLangCode: string,
allLangCodes: string[]
): TSurveyMultipleChoiceElement["choices"][number] => {
const label = Object.fromEntries([
...allLangCodes.map((code) => [code, choice.label[code] ?? ""]),
[selectedLangCode, newLabel],
]) as TI18nString;
return { ...choice, label };
};
export const BulkEditOptionsModal = ({
isOpen,
onClose,
regularChoices,
onSave,
element,
localSurvey,
selectedLanguageCode,
surveyLanguageCodes,
locale,
}: BulkEditOptionsModalProps): JSX.Element => {
const { t } = useTranslation();
const [textareaValue, setTextareaValue] = useState("");
const [validationError, setValidationError] = useState<string | null>(null);
const selectedLanguageName = useMemo(() => {
if (localSurvey.languages.length <= 1) return null;
const code =
selectedLanguageCode === "default"
? localSurvey.languages.find((lang) => lang.default)?.language.code
: selectedLanguageCode;
return code ? getLanguageLabel(code, locale) : null;
}, [localSurvey.languages, selectedLanguageCode, locale]);
useEffect(() => {
if (isOpen) {
setTextareaValue(regularChoices.map((c) => c.label[selectedLanguageCode] || "").join("\n"));
setValidationError(null);
}
}, [isOpen, regularChoices, selectedLanguageCode]);
const validateRemovedOptions = (newLabels: string[]): string | null => {
const originalLabels = regularChoices.map((c) => c.label[selectedLanguageCode] || "");
const missingLabels = originalLabels.filter((label) => label && !newLabels.includes(label));
if (missingLabels.length === 0) return null;
// Find which choices have missing labels and check if they're used in logic
const choicesWithMissingLabels = missingLabels
.map((label) => regularChoices.find((c) => c.label[selectedLanguageCode] === label))
.filter((c): c is TSurveyMultipleChoiceElement["choices"][number] => c !== undefined);
// Get all elements to find which block has the logic
const allElements = getElementsFromBlocks(localSurvey.blocks);
// Build detailed error info: option label -> block name where it's used
const problematicOptions: { optionLabel: string; blockName: string }[] = [];
for (const choice of choicesWithMissingLabels) {
const elementIndex = findOptionUsedInLogic(localSurvey, element.id, choice.id);
if (elementIndex !== -1) {
const elementWithLogic = allElements[elementIndex];
// Find which block contains this element
const { block } = findElementLocation(localSurvey, elementWithLogic.id);
if (block) {
const optionLabel = choice.label[selectedLanguageCode] || "";
problematicOptions.push({ optionLabel, blockName: block.name });
}
}
}
if (problematicOptions.length === 0) return null;
// Format: "Option '3' is used in logic at 'Block Name'"
const details = problematicOptions.map((opt) => `"${opt.optionLabel}" → ${opt.blockName}`).join(", ");
return t("environments.surveys.edit.options_used_in_logic_bulk_error", {
questionIndexes: details,
});
};
const handleSave = () => {
const newLabels = parseUniqueLines(textareaValue);
const error = validateRemovedOptions(newLabels);
if (error) {
setValidationError(error);
return;
}
const updatedChoices = newLabels.map((label, idx) =>
idx < regularChoices.length
? updateChoiceLabel(regularChoices[idx], label, selectedLanguageCode, surveyLanguageCodes)
: { id: createId(), label: createI18nString(label, surveyLanguageCodes) }
);
onSave(updatedChoices);
onClose();
toast.success(t("environments.surveys.edit.changes_saved"));
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl space-y-4">
<DialogHeader>
<DialogTitle>
{selectedLanguageName
? t("environments.surveys.edit.bulk_edit_options_for", { language: selectedLanguageName })
: t("environments.surveys.edit.bulk_edit_options")}
</DialogTitle>
<DialogDescription>{t("environments.surveys.edit.bulk_edit_description")}</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<textarea
value={textareaValue}
onChange={(e) => {
setTextareaValue(e.target.value);
setValidationError(null);
}}
onKeyDown={(e) => {
if (e.shiftKey && e.key === "Enter") {
e.preventDefault();
handleSave();
}
}}
rows={15}
className="focus:border-brand w-full rounded-md border border-slate-300 bg-white p-3 font-mono text-sm focus:outline-none"
placeholder={t("environments.surveys.edit.bulk_edit_description")}
/>
{validationError && <div className="text-sm text-red-600">{validationError}</div>}
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button onClick={handleSave}>{t("environments.surveys.edit.update_options")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -8,12 +8,14 @@ import { PlusIcon } from "lucide-react";
import { type JSX, useEffect, useMemo, useRef, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
import { TI18nString } from "@formbricks/types/i18n";
import { TSurveyElementTypeEnum, TSurveyMultipleChoiceElement } from "@formbricks/types/surveys/elements";
import { TShuffleOption, TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { ElementFormInput } from "@/modules/survey/components/element-form-input";
import { BulkEditOptionsModal } from "@/modules/survey/editor/components/bulk-edit-options-modal";
import { ElementOptionChoice } from "@/modules/survey/editor/components/element-option-choice";
import { findOptionUsedInLogic } from "@/modules/survey/editor/lib/utils";
import { Button } from "@/modules/ui/components/button";
@@ -49,6 +51,7 @@ export const MultipleChoiceElementForm = ({
const lastChoiceRef = useRef<HTMLInputElement>(null);
const [isNew, setIsNew] = useState(true);
const [isInvalidValue, setisInvalidValue] = useState<string | null>(null);
const [isBulkEditOpen, setIsBulkEditOpen] = useState(false);
const elementRef = useRef<HTMLInputElement>(null);
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
@@ -90,11 +93,31 @@ export const MultipleChoiceElementForm = ({
[element.choices]
);
// Get the display name for the selected language (for multi-language surveys)
const bulkEditButtonLabel = useMemo(() => {
if (localSurvey.languages.length <= 1) {
return t("environments.surveys.edit.bulk_edit");
}
const languageCode =
selectedLanguageCode === "default"
? localSurvey.languages.find((lang) => lang.default)?.language.code
: selectedLanguageCode;
const languageName = languageCode ? getLanguageLabel(languageCode, locale) : "";
return `${t("environments.surveys.edit.bulk_edit")} (${languageName})`;
}, [localSurvey.languages, selectedLanguageCode, locale, t]);
const ensureSpecialChoicesOrder = (choices: TSurveyMultipleChoiceElement["choices"]) => {
const regularChoicesFromInput = choices.filter((c) => c.id !== "other" && c.id !== "none");
const otherChoice = choices.find((c) => c.id === "other");
const noneChoice = choices.find((c) => c.id === "none");
// [regularChoices, otherChoice, noneChoice]
return [...regularChoices, ...(otherChoice ? [otherChoice] : []), ...(noneChoice ? [noneChoice] : [])];
return [
...regularChoicesFromInput,
...(otherChoice ? [otherChoice] : []),
...(noneChoice ? [noneChoice] : []),
];
};
const addChoice = (choiceIdx?: number) => {
@@ -283,7 +306,7 @@ export const MultipleChoiceElementForm = ({
updateElement(elementIdx, { choices: newChoices });
}}>
<SortableContext items={element.choices} strategy={verticalListSortingStrategy}>
<div className="flex flex-col gap-2" ref={parent}>
<div className="flex max-h-[25dvh] flex-col gap-2 overflow-y-auto py-1 pr-1" ref={parent}>
{element.choices?.map((choice, choiceIdx) => (
<ElementOptionChoice
key={choice.id}
@@ -308,6 +331,9 @@ export const MultipleChoiceElementForm = ({
</div>
</SortableContext>
</DndContext>
</div>
<div className="mt-2">
<div className="mt-2 flex items-center justify-between space-x-2">
<div className="flex gap-2">
{specialChoices.map((specialChoice) => {
@@ -323,6 +349,9 @@ export const MultipleChoiceElementForm = ({
</Button>
);
})}
<Button size="sm" variant="secondary" type="button" onClick={() => setIsBulkEditOpen(true)}>
{bulkEditButtonLabel}
</Button>
</div>
<Button
size="sm"
@@ -352,6 +381,23 @@ export const MultipleChoiceElementForm = ({
</div>
</div>
</div>
<BulkEditOptionsModal
isOpen={isBulkEditOpen}
onClose={() => setIsBulkEditOpen(false)}
regularChoices={regularChoices}
onSave={(updatedChoices) => {
const newChoices = ensureSpecialChoicesOrder([
...updatedChoices,
...element.choices.filter((c) => c.id === "other" || c.id === "none"),
]);
updateElement(elementIdx, { choices: newChoices });
}}
element={element}
localSurvey={localSurvey}
selectedLanguageCode={selectedLanguageCode}
surveyLanguageCodes={surveyLanguageCodes}
locale={locale}
/>
</form>
);
};

View File

@@ -79,7 +79,9 @@ export const LinkSurveyWrapper = ({
styling={styling}
onBackgroundLoaded={handleBackgroundLoaded}>
<div className="flex max-h-dvh min-h-dvh items-center justify-center overflow-clip">
{!styling.isLogoHidden && (project.logo?.url || styling.logo?.url) && <ClientLogo projectLogo={project.logo} surveyLogo={styling.logo} />}
{!styling.isLogoHidden && (project.logo?.url || styling.logo?.url) && (
<ClientLogo projectLogo={project.logo} surveyLogo={styling.logo} />
)}
<div className="h-full w-full max-w-4xl space-y-6 px-1.5">
{isPreview && (
<div className="fixed left-0 top-0 flex w-full items-center justify-between bg-slate-600 p-2 px-4 text-center text-sm text-white shadow-sm">

View File

@@ -15,7 +15,12 @@ interface ClientLogoProps {
previewSurvey?: boolean;
}
export const ClientLogo = ({ environmentId, projectLogo, surveyLogo, previewSurvey = false }: ClientLogoProps) => {
export const ClientLogo = ({
environmentId,
projectLogo,
surveyLogo,
previewSurvey = false,
}: ClientLogoProps) => {
const { t } = useTranslation();
const logoToUse = surveyLogo?.url ? surveyLogo : projectLogo;

View File

@@ -18,7 +18,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between gap-2 rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm hover:border-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-slate-400",
"flex h-9 w-full items-center justify-between gap-2 rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-1 hover:enabled:border-slate-400 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-slate-400",
className
)}
{...props}>

View File

@@ -102,7 +102,7 @@
"lucide-react": "0.507.0",
"markdown-it": "14.1.0",
"mime-types": "3.0.1",
"next": "15.5.7",
"next": "15.5.9",
"next-auth": "4.24.12",
"next-safe-action": "7.10.8",
"node-fetch": "3.3.2",

View File

@@ -20,7 +20,7 @@ test.describe("Invite, accept and remove organization member", async () => {
await page.locator('[data-testid="members-loading-card"]:first-child').waitFor({ state: "hidden" });
await page.getByRole("link", { name: "Access Control" }).click();
await page.getByRole("link", { name: "Members & Teams" }).click();
// Add member button
await expect(page.getByRole("button", { name: "Invite member" })).toBeVisible();
@@ -131,8 +131,8 @@ test.describe("Create, update and delete team", async () => {
await page.waitForURL(/\/environments\/[^/]+\/settings\/general/);
await page.waitForTimeout(2000);
await expect(page.getByText("Access Control")).toBeVisible();
await page.getByText("Access Control").click();
await expect(page.getByText("Members & Teams")).toBeVisible();
await page.getByText("Members & Teams").click();
await page.waitForURL(/\/environments\/[^/]+\/settings\/teams/);
await expect(page.getByRole("button", { name: "Create new team" })).toBeVisible();
await page.getByRole("button", { name: "Create new team" }).click();

View File

@@ -1,4 +1,3 @@
version: "3.3"
x-environment: &environment
environment:
######################################################## REQUIRED ########################################################

View File

@@ -496,7 +496,7 @@ EOF
if [[ $insert_traefik == "y" ]]; then
cat >> "$services_snippet_file" << EOF
traefik:
image: "traefik:v2.11.29"
image: "traefik:v2.11.31"
restart: always
container_name: "traefik"
depends_on:
@@ -525,7 +525,7 @@ EOF
cat > "$services_snippet_file" << EOF
traefik:
image: "traefik:v2.11.29"
image: "traefik:v2.11.31"
restart: always
container_name: "traefik"
depends_on:

View File

@@ -109,7 +109,7 @@ Modify the configuration to enforce SSL. The rest of the configuration should re
<<: *environment
traefik:
image: "traefik:v2.7"
image: "traefik:v2.11.31"
restart: always
container_name: "traefik"
depends_on:

View File

@@ -46,7 +46,8 @@
"react-dom": "19.1.2"
},
"devDependencies": {
"@azure/microsoft-playwright-testing": "1.0.0-beta.7",
"@azure/identity": "4.13.0",
"@azure/playwright": "1.0.0",
"@formbricks/eslint-config": "workspace:*",
"@playwright/test": "1.56.1",
"eslint": "8.57.0",

View File

@@ -1,5 +1,5 @@
export const JS_LOCAL_STORAGE_KEY = "formbricks-js";
export const LEGACY_JS_WEBSITE_LOCAL_STORAGE_KEY = "formbricks-js-website";
export const LEGACY_JS_APP_LOCAL_STORAGE_KEY = "formbricks-js-app";
export const CONTAINER_ID = "formbricks-app-container";
export const CONTAINER_ID = "formbricks-modal-container";
export const RECAPTCHA_SCRIPT_ID = "formbricks-recaptcha-script";

View File

@@ -7,7 +7,7 @@ import { getIsSetup, setIsSetup } from "@/lib/common/status";
import { filterSurveys, getIsDebug, isNowExpired, wrapThrows } from "@/lib/common/utils";
import { fetchEnvironmentState } from "@/lib/environment/state";
import { checkPageUrl } from "@/lib/survey/no-code-action";
import { addWidgetContainer, removeWidgetContainer, setIsSurveyRunning } from "@/lib/survey/widget";
import { closeSurvey } from "@/lib/survey/widget";
import { DEFAULT_USER_STATE_NO_USER_ID } from "@/lib/user/state";
import { sendUpdatesToBackend } from "@/lib/user/update";
import {
@@ -142,9 +142,6 @@ export const setup = async (
});
}
logger.debug("Adding widget container to DOM");
addWidgetContainer();
if (
existingConfig?.environment &&
existingConfig.environmentId === configInput.environmentId &&
@@ -344,10 +341,7 @@ export const tearDown = (): void => {
filteredSurveys,
});
// remove container element from DOM
removeWidgetContainer();
addWidgetContainer();
setIsSurveyRunning(false);
closeSurvey();
};
export const handleErrorOnFirstSetup = (e: { code: string; responseMessage: string }): Promise<never> => {

View File

@@ -290,12 +290,13 @@ describe("setup.ts", () => {
test("resets user state to default", () => {
const mockConfig = {
get: vi.fn().mockReturnValue({
environment: { data: { surveys: [] } },
user: { data: { userId: "XYZ" } },
}),
update: vi.fn(),
};
getInstanceConfigMock.mockReturnValueOnce(mockConfig as unknown as Config);
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as Config);
tearDown();

View File

@@ -174,9 +174,8 @@ export const renderWidget = async (
export const closeSurvey = (): void => {
const config = Config.getInstance();
// remove container element from DOM
// remove the survey modal container from DOM
removeWidgetContainer();
addWidgetContainer();
const { environment, user } = config.get();
const filteredSurveys = filterSurveys(environment, user);

View File

@@ -1,24 +1,14 @@
import { ServiceOS, getServiceConfig } from "@azure/microsoft-playwright-testing";
import { defineConfig } from "@playwright/test";
import config from "./playwright.config";
import { createAzurePlaywrightConfig, ServiceAuth, ServiceOS } from '@azure/playwright';
import { defineConfig } from '@playwright/test';
import config from './playwright.config';
/* Learn more about service configuration at https://aka.ms/mpt/config */
/* Learn more about service configuration at https://aka.ms/pww/docs/config */
export default defineConfig(
config,
getServiceConfig(config, {
exposeNetwork: "<loopback>",
timeout: 120000, // Increased timeout for cloud environment with network latency
createAzurePlaywrightConfig(config, {
exposeNetwork: '<loopback>',
connectTimeout: 3 * 60 * 1000, // 3 minutes
os: ServiceOS.LINUX,
useCloudHostedBrowsers: true, // Set to false if you want to only use reporting and not cloud hosted browsers
}),
{
/*
Playwright Testing service reporter is added by default.
This will override any reporter options specified in the base playwright config.
If you are using more reporters, please update your configuration accordingly.
*/
reporter: [["list"], ["@azure/microsoft-playwright-testing/reporter"]],
retries: 2, // Always retry in cloud environment due to potential network/timing issues
maxFailures: undefined, // Don't stop on first failure to avoid cascading shutdowns with high parallelism
}
serviceAuthType: ServiceAuth.ACCESS_TOKEN
})
);

360
pnpm-lock.yaml generated
View File

@@ -26,9 +26,12 @@ importers:
specifier: 19.1.2
version: 19.1.2(react@19.1.2)
devDependencies:
'@azure/microsoft-playwright-testing':
specifier: 1.0.0-beta.7
version: 1.0.0-beta.7(@playwright/test@1.56.1)
'@azure/identity':
specifier: 4.13.0
version: 4.13.0
'@azure/playwright':
specifier: 1.0.0
version: 1.0.0(@playwright/test@1.56.1)
'@formbricks/eslint-config':
specifier: workspace:*
version: link:packages/config-eslint
@@ -269,7 +272,7 @@ importers:
version: 0.0.38(react-dom@19.1.2(react@19.1.2))(react@19.1.2)
'@sentry/nextjs':
specifier: 10.5.0
version: 10.5.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.7(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.1.2(react@19.1.2))(react@19.1.2))(react@19.1.2)(webpack@5.99.8(esbuild@0.25.10))
version: 10.5.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.1.2(react@19.1.2))(react@19.1.2))(react@19.1.2)(webpack@5.99.8(esbuild@0.25.10))
'@t3-oss/env-nextjs':
specifier: 0.13.4
version: 0.13.4(arktype@2.1.25)(typescript@5.8.3)(zod@3.24.4)
@@ -358,14 +361,14 @@ importers:
specifier: 3.0.1
version: 3.0.1
next:
specifier: 15.5.7
version: 15.5.7(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)
specifier: 15.5.9
version: 15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)
next-auth:
specifier: 4.24.12
version: 4.24.12(patch_hash=bdy3m55bopfzpysceipfxj5eei)(next@15.5.7(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.1.2(react@19.1.2))(react@19.1.2))(nodemailer@7.0.11)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)
version: 4.24.12(patch_hash=bdy3m55bopfzpysceipfxj5eei)(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.1.2(react@19.1.2))(react@19.1.2))(nodemailer@7.0.11)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)
next-safe-action:
specifier: 7.10.8
version: 7.10.8(next@15.5.7(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.1.2(react@19.1.2))(react@19.1.2))(react-dom@19.1.2(react@19.1.2))(react@19.1.2)(zod@3.24.4)
version: 7.10.8(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.1.2(react@19.1.2))(react@19.1.2))(react-dom@19.1.2(react@19.1.2))(react@19.1.2)(zod@3.24.4)
node-fetch:
specifier: 3.3.2
version: 3.3.2
@@ -1347,10 +1350,6 @@ packages:
resolution: {integrity: sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==}
engines: {node: '>=20.0.0'}
'@azure/core-xml@1.5.0':
resolution: {integrity: sha512-D/sdlJBMJfx7gqoj66PKVmhDDaU6TKA49ptcolxdas29X7AfvLTmfAGLjAcIMBK7UZ2o4lygHIqVckOlQU3xWw==}
engines: {node: '>=20.0.0'}
'@azure/identity@4.13.0':
resolution: {integrity: sha512-uWC0fssc+hs1TGGVkkghiaFkkS7NkTxfnCH+Hdg+yTehTpMcehpok4PgUKKdyCH+9ldu6FhiHRv84Ntqj1vVcw==}
engines: {node: '>=20.0.0'}
@@ -1367,13 +1366,6 @@ packages:
resolution: {integrity: sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==}
engines: {node: '>=20.0.0'}
'@azure/microsoft-playwright-testing@1.0.0-beta.7':
resolution: {integrity: sha512-Y6C35LWUfLevHu5NG+7vvFfhpmUrGWKRumcz7/CSCmWlx8RVfWgP6NuL8rIPDuTeJyjaTczNfeg1ppGW26TjBw==}
engines: {node: '>=18.0.0'}
deprecated: This package has been deprecated and will no longer be maintained after March 8, 2026. Upgrade to the replacement package, @azure/playwright, to continue receiving updates.
peerDependencies:
'@playwright/test': ^1.43.1
'@azure/msal-browser@4.26.0':
resolution: {integrity: sha512-Ie3SZ4IMrf9lSwWVzzJrhTPE+g9+QDUfeor1LKMBQzcblp+3J/U1G8hMpNSfLL7eA5F/DjjPXkATJ5JRUdDJLA==}
engines: {node: '>=0.8.0'}
@@ -1386,13 +1378,11 @@ packages:
resolution: {integrity: sha512-HszfqoC+i2C9+BRDQfuNUGp15Re7menIhCEbFCQ49D3KaqEDrgZIgQ8zSct4T59jWeUIL9N/Dwiv4o2VueTdqQ==}
engines: {node: '>=16'}
'@azure/storage-blob@12.29.1':
resolution: {integrity: sha512-7ktyY0rfTM0vo7HvtK6E3UvYnI9qfd6Oz6z/+92VhGRveWng3kJwMKeUpqmW/NmwcDNbxHpSlldG+vsUnRFnBg==}
engines: {node: '>=20.0.0'}
'@azure/storage-common@12.1.1':
resolution: {integrity: sha512-eIOH1pqFwI6UmVNnDQvmFeSg0XppuzDLFeUNO/Xht7ODAzRLgGDh7h550pSxoA+lPDxBl1+D2m/KG3jWzCUjTg==}
'@azure/playwright@1.0.0':
resolution: {integrity: sha512-HG0XuYT0z7TcyffJZrEZ9fWrP/WsK/jDAmKFB7MuYNy9WMW0oQItUy+o6uIv2IiZzUeO8OpyhompOhpFddf+eQ==}
engines: {node: '>=20.0.0'}
peerDependencies:
'@playwright/test': ^1.47.0
'@babel/code-frame@7.27.1':
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
@@ -1608,6 +1598,9 @@ packages:
'@emnapi/runtime@1.6.0':
resolution: {integrity: sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA==}
'@emnapi/runtime@1.7.1':
resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==}
'@emnapi/wasi-threads@1.1.0':
resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==}
@@ -2021,8 +2014,8 @@ packages:
cpu: [arm64]
os: [darwin]
'@img/sharp-darwin-arm64@0.34.4':
resolution: {integrity: sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA==}
'@img/sharp-darwin-arm64@0.34.5':
resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [darwin]
@@ -2033,8 +2026,8 @@ packages:
cpu: [x64]
os: [darwin]
'@img/sharp-darwin-x64@0.34.4':
resolution: {integrity: sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg==}
'@img/sharp-darwin-x64@0.34.5':
resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [darwin]
@@ -2044,8 +2037,8 @@ packages:
cpu: [arm64]
os: [darwin]
'@img/sharp-libvips-darwin-arm64@1.2.3':
resolution: {integrity: sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw==}
'@img/sharp-libvips-darwin-arm64@1.2.4':
resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
cpu: [arm64]
os: [darwin]
@@ -2054,8 +2047,8 @@ packages:
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-darwin-x64@1.2.3':
resolution: {integrity: sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA==}
'@img/sharp-libvips-darwin-x64@1.2.4':
resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
cpu: [x64]
os: [darwin]
@@ -2064,8 +2057,8 @@ packages:
cpu: [arm64]
os: [linux]
'@img/sharp-libvips-linux-arm64@1.2.3':
resolution: {integrity: sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==}
'@img/sharp-libvips-linux-arm64@1.2.4':
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
@@ -2074,8 +2067,8 @@ packages:
cpu: [arm]
os: [linux]
'@img/sharp-libvips-linux-arm@1.2.3':
resolution: {integrity: sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==}
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
@@ -2084,18 +2077,23 @@ packages:
cpu: [ppc64]
os: [linux]
'@img/sharp-libvips-linux-ppc64@1.2.3':
resolution: {integrity: sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==}
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
'@img/sharp-libvips-linux-s390x@1.1.0':
resolution: {integrity: sha512-xukSwvhguw7COyzvmjydRb3x/09+21HykyapcZchiCUkTThEQEOMtBj9UhkaBRLuBrgLFzQ2wbxdeCCJW/jgJA==}
cpu: [s390x]
os: [linux]
'@img/sharp-libvips-linux-s390x@1.2.3':
resolution: {integrity: sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==}
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
@@ -2104,8 +2102,8 @@ packages:
cpu: [x64]
os: [linux]
'@img/sharp-libvips-linux-x64@1.2.3':
resolution: {integrity: sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==}
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
@@ -2114,8 +2112,8 @@ packages:
cpu: [arm64]
os: [linux]
'@img/sharp-libvips-linuxmusl-arm64@1.2.3':
resolution: {integrity: sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==}
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
@@ -2124,8 +2122,8 @@ packages:
cpu: [x64]
os: [linux]
'@img/sharp-libvips-linuxmusl-x64@1.2.3':
resolution: {integrity: sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==}
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
@@ -2135,8 +2133,8 @@ packages:
cpu: [arm64]
os: [linux]
'@img/sharp-linux-arm64@0.34.4':
resolution: {integrity: sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==}
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
@@ -2147,26 +2145,32 @@ packages:
cpu: [arm]
os: [linux]
'@img/sharp-linux-arm@0.34.4':
resolution: {integrity: sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==}
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
'@img/sharp-linux-ppc64@0.34.4':
resolution: {integrity: sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==}
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
'@img/sharp-linux-s390x@0.34.1':
resolution: {integrity: sha512-7s0KX2tI9mZI2buRipKIw2X1ufdTeaRgwmRabt5bi9chYfhur+/C1OXg3TKg/eag1W+6CCWLVmSauV1owmRPxA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
'@img/sharp-linux-s390x@0.34.4':
resolution: {integrity: sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==}
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
@@ -2177,8 +2181,8 @@ packages:
cpu: [x64]
os: [linux]
'@img/sharp-linux-x64@0.34.4':
resolution: {integrity: sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==}
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
@@ -2189,8 +2193,8 @@ packages:
cpu: [arm64]
os: [linux]
'@img/sharp-linuxmusl-arm64@0.34.4':
resolution: {integrity: sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==}
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
@@ -2201,8 +2205,8 @@ packages:
cpu: [x64]
os: [linux]
'@img/sharp-linuxmusl-x64@0.34.4':
resolution: {integrity: sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==}
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
@@ -2212,13 +2216,13 @@ packages:
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [wasm32]
'@img/sharp-wasm32@0.34.4':
resolution: {integrity: sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==}
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [wasm32]
'@img/sharp-win32-arm64@0.34.4':
resolution: {integrity: sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA==}
'@img/sharp-win32-arm64@0.34.5':
resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [win32]
@@ -2229,8 +2233,8 @@ packages:
cpu: [ia32]
os: [win32]
'@img/sharp-win32-ia32@0.34.4':
resolution: {integrity: sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw==}
'@img/sharp-win32-ia32@0.34.5':
resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ia32]
os: [win32]
@@ -2241,8 +2245,8 @@ packages:
cpu: [x64]
os: [win32]
'@img/sharp-win32-x64@0.34.4':
resolution: {integrity: sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig==}
'@img/sharp-win32-x64@0.34.5':
resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [win32]
@@ -2432,8 +2436,8 @@ packages:
'@neoconfetti/react@1.0.0':
resolution: {integrity: sha512-klcSooChXXOzIm+SE5IISIAn3bYzYfPjbX7D7HoqZL84oAfgREeSg5vSIaSFH+DaGzzvImTyWe1OyrJ67vik4A==}
'@next/env@15.5.7':
resolution: {integrity: sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg==}
'@next/env@15.5.9':
resolution: {integrity: sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg==}
'@next/eslint-plugin-next@15.3.2':
resolution: {integrity: sha512-ijVRTXBgnHT33aWnDtmlG+LJD+5vhc9AKTJPquGG5NKXjpKNjc62woIhFtrAcWdBobt8kqjCoaJ0q6sDQoX7aQ==}
@@ -5481,6 +5485,9 @@ packages:
caniuse-lite@1.0.30001751:
resolution: {integrity: sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==}
caniuse-lite@1.0.30001760:
resolution: {integrity: sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==}
chai@5.3.3:
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
engines: {node: '>=18'}
@@ -6428,10 +6435,6 @@ packages:
resolution: {integrity: sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==}
hasBin: true
fast-xml-parser@5.3.0:
resolution: {integrity: sha512-gkWGshjYcQCF+6qtlrqBqELqNqnt4CxruY6UVAWWnqb3DQ6qaNFEIKqzYep1XzHLM/QtrHVCxyPOtTk4LTQ7Aw==}
hasBin: true
fastest-stable-stringify@2.0.2:
resolution: {integrity: sha512-bijHueCGd0LqqNK9b5oCMHc0MluJAx0cwqASgbWMvkO01lCYgIhacVRLcaDz3QnyYIRNJRDwMb41VuT6pHJ91Q==}
@@ -7751,9 +7754,10 @@ packages:
zod:
optional: true
next@15.5.7:
resolution: {integrity: sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==}
next@15.5.9:
resolution: {integrity: sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==}
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
deprecated: This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details.
hasBin: true
peerDependencies:
'@opentelemetry/api': ^1.1.0
@@ -8884,8 +8888,8 @@ packages:
resolution: {integrity: sha512-1j0w61+eVxu7DawFJtnfYcvSv6qPFvfTaqzTQ2BLknVhHTwGS8sc63ZBF4rzkWMBVKybo4S5OBtDdZahh2A1xg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
sharp@0.34.4:
resolution: {integrity: sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==}
sharp@0.34.5:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
shebang-command@2.0.0:
@@ -11406,11 +11410,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@azure/core-xml@1.5.0':
dependencies:
fast-xml-parser: 5.3.0
tslib: 2.8.1
'@azure/identity@4.13.0':
dependencies:
'@azure/abort-controller': 2.1.2
@@ -11464,17 +11463,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@azure/microsoft-playwright-testing@1.0.0-beta.7(@playwright/test@1.56.1)':
dependencies:
'@azure/core-rest-pipeline': 1.22.1
'@azure/identity': 4.13.0
'@azure/logger': 1.3.0
'@azure/storage-blob': 12.29.1
'@playwright/test': 1.56.1
tslib: 2.8.1
transitivePeerDependencies:
- supports-color
'@azure/msal-browser@4.26.0':
dependencies:
'@azure/msal-common': 15.13.1
@@ -11487,35 +11475,12 @@ snapshots:
jsonwebtoken: 9.0.2
uuid: 8.3.2
'@azure/storage-blob@12.29.1':
'@azure/playwright@1.0.0(@playwright/test@1.56.1)':
dependencies:
'@azure/abort-controller': 2.1.2
'@azure/core-auth': 1.10.1
'@azure/core-client': 1.10.1
'@azure/core-http-compat': 2.3.1
'@azure/core-lro': 2.7.2
'@azure/core-paging': 1.6.2
'@azure/core-rest-pipeline': 1.22.1
'@azure/core-tracing': 1.3.1
'@azure/core-util': 1.13.1
'@azure/core-xml': 1.5.0
'@azure/logger': 1.3.0
'@azure/storage-common': 12.1.1
events: 3.3.0
tslib: 2.8.1
transitivePeerDependencies:
- supports-color
'@azure/storage-common@12.1.1':
dependencies:
'@azure/abort-controller': 2.1.2
'@azure/core-auth': 1.10.1
'@azure/core-http-compat': 2.3.1
'@azure/core-rest-pipeline': 1.22.1
'@azure/core-tracing': 1.3.1
'@azure/core-util': 1.13.1
'@azure/logger': 1.3.0
events: 3.3.0
'@playwright/test': 1.56.1
tslib: 2.8.1
transitivePeerDependencies:
- supports-color
@@ -11830,6 +11795,11 @@ snapshots:
tslib: 2.8.1
optional: true
'@emnapi/runtime@1.7.1':
dependencies:
tslib: 2.8.1
optional: true
'@emnapi/wasi-threads@1.1.0':
dependencies:
tslib: 2.8.1
@@ -12113,9 +12083,9 @@ snapshots:
'@img/sharp-libvips-darwin-arm64': 1.1.0
optional: true
'@img/sharp-darwin-arm64@0.34.4':
'@img/sharp-darwin-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.2.3
'@img/sharp-libvips-darwin-arm64': 1.2.4
optional: true
'@img/sharp-darwin-x64@0.34.1':
@@ -12123,63 +12093,66 @@ snapshots:
'@img/sharp-libvips-darwin-x64': 1.1.0
optional: true
'@img/sharp-darwin-x64@0.34.4':
'@img/sharp-darwin-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.2.3
'@img/sharp-libvips-darwin-x64': 1.2.4
optional: true
'@img/sharp-libvips-darwin-arm64@1.1.0':
optional: true
'@img/sharp-libvips-darwin-arm64@1.2.3':
'@img/sharp-libvips-darwin-arm64@1.2.4':
optional: true
'@img/sharp-libvips-darwin-x64@1.1.0':
optional: true
'@img/sharp-libvips-darwin-x64@1.2.3':
'@img/sharp-libvips-darwin-x64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm64@1.1.0':
optional: true
'@img/sharp-libvips-linux-arm64@1.2.3':
'@img/sharp-libvips-linux-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm@1.1.0':
optional: true
'@img/sharp-libvips-linux-arm@1.2.3':
'@img/sharp-libvips-linux-arm@1.2.4':
optional: true
'@img/sharp-libvips-linux-ppc64@1.1.0':
optional: true
'@img/sharp-libvips-linux-ppc64@1.2.3':
'@img/sharp-libvips-linux-ppc64@1.2.4':
optional: true
'@img/sharp-libvips-linux-riscv64@1.2.4':
optional: true
'@img/sharp-libvips-linux-s390x@1.1.0':
optional: true
'@img/sharp-libvips-linux-s390x@1.2.3':
'@img/sharp-libvips-linux-s390x@1.2.4':
optional: true
'@img/sharp-libvips-linux-x64@1.1.0':
optional: true
'@img/sharp-libvips-linux-x64@1.2.3':
'@img/sharp-libvips-linux-x64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.1.0':
optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.2.3':
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-x64@1.1.0':
optional: true
'@img/sharp-libvips-linuxmusl-x64@1.2.3':
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
optional: true
'@img/sharp-linux-arm64@0.34.1':
@@ -12187,9 +12160,9 @@ snapshots:
'@img/sharp-libvips-linux-arm64': 1.1.0
optional: true
'@img/sharp-linux-arm64@0.34.4':
'@img/sharp-linux-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.2.3
'@img/sharp-libvips-linux-arm64': 1.2.4
optional: true
'@img/sharp-linux-arm@0.34.1':
@@ -12197,14 +12170,19 @@ snapshots:
'@img/sharp-libvips-linux-arm': 1.1.0
optional: true
'@img/sharp-linux-arm@0.34.4':
'@img/sharp-linux-arm@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.2.3
'@img/sharp-libvips-linux-arm': 1.2.4
optional: true
'@img/sharp-linux-ppc64@0.34.4':
'@img/sharp-linux-ppc64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-ppc64': 1.2.3
'@img/sharp-libvips-linux-ppc64': 1.2.4
optional: true
'@img/sharp-linux-riscv64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-riscv64': 1.2.4
optional: true
'@img/sharp-linux-s390x@0.34.1':
@@ -12212,9 +12190,9 @@ snapshots:
'@img/sharp-libvips-linux-s390x': 1.1.0
optional: true
'@img/sharp-linux-s390x@0.34.4':
'@img/sharp-linux-s390x@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.2.3
'@img/sharp-libvips-linux-s390x': 1.2.4
optional: true
'@img/sharp-linux-x64@0.34.1':
@@ -12222,9 +12200,9 @@ snapshots:
'@img/sharp-libvips-linux-x64': 1.1.0
optional: true
'@img/sharp-linux-x64@0.34.4':
'@img/sharp-linux-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.2.3
'@img/sharp-libvips-linux-x64': 1.2.4
optional: true
'@img/sharp-linuxmusl-arm64@0.34.1':
@@ -12232,9 +12210,9 @@ snapshots:
'@img/sharp-libvips-linuxmusl-arm64': 1.1.0
optional: true
'@img/sharp-linuxmusl-arm64@0.34.4':
'@img/sharp-linuxmusl-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.2.3
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
optional: true
'@img/sharp-linuxmusl-x64@0.34.1':
@@ -12242,9 +12220,9 @@ snapshots:
'@img/sharp-libvips-linuxmusl-x64': 1.1.0
optional: true
'@img/sharp-linuxmusl-x64@0.34.4':
'@img/sharp-linuxmusl-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.2.3
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
optional: true
'@img/sharp-wasm32@0.34.1':
@@ -12252,24 +12230,24 @@ snapshots:
'@emnapi/runtime': 1.6.0
optional: true
'@img/sharp-wasm32@0.34.4':
'@img/sharp-wasm32@0.34.5':
dependencies:
'@emnapi/runtime': 1.6.0
'@emnapi/runtime': 1.7.1
optional: true
'@img/sharp-win32-arm64@0.34.4':
'@img/sharp-win32-arm64@0.34.5':
optional: true
'@img/sharp-win32-ia32@0.34.1':
optional: true
'@img/sharp-win32-ia32@0.34.4':
'@img/sharp-win32-ia32@0.34.5':
optional: true
'@img/sharp-win32-x64@0.34.1':
optional: true
'@img/sharp-win32-x64@0.34.4':
'@img/sharp-win32-x64@0.34.5':
optional: true
'@intercom/messenger-js-sdk@0.0.14': {}
@@ -12595,13 +12573,13 @@ snapshots:
'@napi-rs/wasm-runtime@0.2.12':
dependencies:
'@emnapi/core': 1.6.0
'@emnapi/runtime': 1.6.0
'@emnapi/runtime': 1.7.1
'@tybys/wasm-util': 0.10.1
optional: true
'@neoconfetti/react@1.0.0': {}
'@next/env@15.5.7': {}
'@next/env@15.5.9': {}
'@next/eslint-plugin-next@15.3.2':
dependencies:
@@ -14128,7 +14106,7 @@ snapshots:
'@sentry/core@10.5.0': {}
'@sentry/nextjs@10.5.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.7(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.1.2(react@19.1.2))(react@19.1.2))(react@19.1.2)(webpack@5.99.8(esbuild@0.25.10))':
'@sentry/nextjs@10.5.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.1.2(react@19.1.2))(react@19.1.2))(react@19.1.2)(webpack@5.99.8(esbuild@0.25.10))':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/semantic-conventions': 1.37.0
@@ -14141,7 +14119,7 @@ snapshots:
'@sentry/vercel-edge': 10.5.0
'@sentry/webpack-plugin': 4.6.0(encoding@0.1.13)(webpack@5.99.8(esbuild@0.25.10))
chalk: 3.0.0
next: 15.5.7(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)
next: 15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)
resolve: 1.22.8
rollup: 4.52.5
stacktrace-parser: 0.1.11
@@ -16072,6 +16050,8 @@ snapshots:
caniuse-lite@1.0.30001751: {}
caniuse-lite@1.0.30001760: {}
chai@5.3.3:
dependencies:
assertion-error: 2.0.1
@@ -17205,10 +17185,6 @@ snapshots:
dependencies:
strnum: 2.1.1
fast-xml-parser@5.3.0:
dependencies:
strnum: 2.1.1
fastest-stable-stringify@2.0.2: {}
fastq@1.19.1:
@@ -18569,13 +18545,13 @@ snapshots:
neo-async@2.6.2: {}
next-auth@4.24.12(patch_hash=bdy3m55bopfzpysceipfxj5eei)(next@15.5.7(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.1.2(react@19.1.2))(react@19.1.2))(nodemailer@7.0.11)(react-dom@19.1.2(react@19.1.2))(react@19.1.2):
next-auth@4.24.12(patch_hash=bdy3m55bopfzpysceipfxj5eei)(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.1.2(react@19.1.2))(react@19.1.2))(nodemailer@7.0.11)(react-dom@19.1.2(react@19.1.2))(react@19.1.2):
dependencies:
'@babel/runtime': 7.28.4
'@panva/hkdf': 1.2.1
cookie: 0.7.2
jose: 4.15.9
next: 15.5.7(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)
next: 15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)
oauth: 0.9.15
openid-client: 5.7.1
preact: 10.26.6
@@ -18586,19 +18562,19 @@ snapshots:
optionalDependencies:
nodemailer: 7.0.11
next-safe-action@7.10.8(next@15.5.7(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.1.2(react@19.1.2))(react@19.1.2))(react-dom@19.1.2(react@19.1.2))(react@19.1.2)(zod@3.24.4):
next-safe-action@7.10.8(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.1.2(react@19.1.2))(react@19.1.2))(react-dom@19.1.2(react@19.1.2))(react@19.1.2)(zod@3.24.4):
dependencies:
next: 15.5.7(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)
next: 15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)
react: 19.1.2
react-dom: 19.1.2(react@19.1.2)
optionalDependencies:
zod: 3.24.4
next@15.5.7(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.1.2(react@19.1.2))(react@19.1.2):
next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.1.2(react@19.1.2))(react@19.1.2):
dependencies:
'@next/env': 15.5.7
'@next/env': 15.5.9
'@swc/helpers': 0.5.15
caniuse-lite: 1.0.30001751
caniuse-lite: 1.0.30001760
postcss: 8.4.31
react: 19.1.2
react-dom: 19.1.2(react@19.1.2)
@@ -18614,7 +18590,7 @@ snapshots:
'@next/swc-win32-x64-msvc': 15.5.7
'@opentelemetry/api': 1.9.0
'@playwright/test': 1.56.1
sharp: 0.34.4
sharp: 0.34.5
transitivePeerDependencies:
- '@babel/core'
- babel-plugin-macros
@@ -19809,34 +19785,36 @@ snapshots:
'@img/sharp-win32-ia32': 0.34.1
'@img/sharp-win32-x64': 0.34.1
sharp@0.34.4:
sharp@0.34.5:
dependencies:
'@img/colour': 1.0.0
detect-libc: 2.1.2
semver: 7.7.3
optionalDependencies:
'@img/sharp-darwin-arm64': 0.34.4
'@img/sharp-darwin-x64': 0.34.4
'@img/sharp-libvips-darwin-arm64': 1.2.3
'@img/sharp-libvips-darwin-x64': 1.2.3
'@img/sharp-libvips-linux-arm': 1.2.3
'@img/sharp-libvips-linux-arm64': 1.2.3
'@img/sharp-libvips-linux-ppc64': 1.2.3
'@img/sharp-libvips-linux-s390x': 1.2.3
'@img/sharp-libvips-linux-x64': 1.2.3
'@img/sharp-libvips-linuxmusl-arm64': 1.2.3
'@img/sharp-libvips-linuxmusl-x64': 1.2.3
'@img/sharp-linux-arm': 0.34.4
'@img/sharp-linux-arm64': 0.34.4
'@img/sharp-linux-ppc64': 0.34.4
'@img/sharp-linux-s390x': 0.34.4
'@img/sharp-linux-x64': 0.34.4
'@img/sharp-linuxmusl-arm64': 0.34.4
'@img/sharp-linuxmusl-x64': 0.34.4
'@img/sharp-wasm32': 0.34.4
'@img/sharp-win32-arm64': 0.34.4
'@img/sharp-win32-ia32': 0.34.4
'@img/sharp-win32-x64': 0.34.4
'@img/sharp-darwin-arm64': 0.34.5
'@img/sharp-darwin-x64': 0.34.5
'@img/sharp-libvips-darwin-arm64': 1.2.4
'@img/sharp-libvips-darwin-x64': 1.2.4
'@img/sharp-libvips-linux-arm': 1.2.4
'@img/sharp-libvips-linux-arm64': 1.2.4
'@img/sharp-libvips-linux-ppc64': 1.2.4
'@img/sharp-libvips-linux-riscv64': 1.2.4
'@img/sharp-libvips-linux-s390x': 1.2.4
'@img/sharp-libvips-linux-x64': 1.2.4
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
'@img/sharp-linux-arm': 0.34.5
'@img/sharp-linux-arm64': 0.34.5
'@img/sharp-linux-ppc64': 0.34.5
'@img/sharp-linux-riscv64': 0.34.5
'@img/sharp-linux-s390x': 0.34.5
'@img/sharp-linux-x64': 0.34.5
'@img/sharp-linuxmusl-arm64': 0.34.5
'@img/sharp-linuxmusl-x64': 0.34.5
'@img/sharp-wasm32': 0.34.5
'@img/sharp-win32-arm64': 0.34.5
'@img/sharp-win32-ia32': 0.34.5
'@img/sharp-win32-x64': 0.34.5
optional: true
shebang-command@2.0.0: