Compare commits

...

2 Commits

Author SHA1 Message Date
Tiago Farto b5fbc6317d fix: address project deletion review feedback 2026-05-07 15:12:16 +00:00
Tiago Farto b951fbcbc8 chore: workspace delete confirmation dialog 2026-05-07 14:50:34 +00:00
23 changed files with 711 additions and 102 deletions
+4 -3
View File
@@ -2212,6 +2212,7 @@
"custom_scripts_warning": "Skripte werden mit vollem Browser-Zugriff ausgeführt. Fügen Sie nur Skripte aus vertrauenswürdigen Quellen hinzu.",
"delete_workspace": "Projekt löschen",
"delete_workspace_confirmation": "Sind Sie sicher, dass Sie {projectName} löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
"delete_workspace_confirmation_name": "Bitte gib {projectName} in das folgende Feld ein, um die endgültige Löschung dieses Projekts zu bestätigen:",
"delete_workspace_name_includes_surveys_responses_people_and_more": "{projectName} inkl. aller Umfragen, Antworten, Personen, Aktionen und Attribute löschen.",
"delete_workspace_settings_description": "Projekt mit allen Umfragen, Antworten, Personen, Aktionen und Attributen löschen. Das kann nicht rückgängig gemacht werden.",
"error_saving_workspace_information": "Fehler beim Speichern der Projektinformationen",
@@ -2429,11 +2430,13 @@
"s": {
"check_inbox_or_spam": "Bitte überprüfe auch deinen Spam-Ordner, falls Du die E-Mail nicht in deinem Posteingang siehst.",
"completed": "Diese kostenlose und quelloffene Umfrage wurde geschlossen.",
"completed_heading": "Abgeschlossen",
"create_your_own": "Erstelle deine eigene",
"enter_pin": "Diese Umfrage ist geschützt. Gib die PIN unten ein.",
"just_curious": "Einfach neugierig?",
"link_invalid": "Diese Umfrage kann nur auf Einladung durchgeführt werden.",
"paused": "Diese Umfrage ist vorübergehend pausiert.",
"paused_heading": "Pausiert",
"please_try_again_with_the_original_link": "Bitte versuche es nochmal mit dem ursprünglichen Link",
"preview_survey_questions": "Vorschau der Fragen.",
"question_preview": "Vorschau der Frage",
@@ -2447,9 +2450,7 @@
"verify_email_before_submission": "Bestätige deine E-Mail, um zu antworten",
"verify_email_before_submission_button": "Überprüfen",
"verify_email_before_submission_description": "Um an dieser Umfrage teilzunehmen, bitte bestätige deine E-Mail",
"want_to_respond": "Möchtest Du antworten?",
"paused_heading": "Pausiert",
"completed_heading": "Abgeschlossen"
"want_to_respond": "Möchtest Du antworten?"
},
"setup": {
"intro": {
+4 -3
View File
@@ -2212,6 +2212,7 @@
"custom_scripts_warning": "Scripts execute with full browser access. Only add scripts from trusted sources.",
"delete_workspace": "Delete Workspace",
"delete_workspace_confirmation": "Are you sure you want to delete {projectName}? This action cannot be undone.",
"delete_workspace_confirmation_name": "Please enter {projectName} in the following field to confirm the definitive deletion of this workspace:",
"delete_workspace_name_includes_surveys_responses_people_and_more": "Delete {projectName} including all surveys, responses, people, actions and attributes.",
"delete_workspace_settings_description": "Delete workspace with all surveys, responses, people, actions and attributes. This cannot be undone.",
"error_saving_workspace_information": "Error saving workspace information",
@@ -2429,11 +2430,13 @@
"s": {
"check_inbox_or_spam": "Please also check your spam folder if you do not see the email in your inbox.",
"completed": "This survey is closed.",
"completed_heading": "Completed",
"create_your_own": "Create your own open-source survey",
"enter_pin": "This survey is protected. Enter the PIN below.",
"just_curious": "Just curious?",
"link_invalid": "This survey can only be taken by invitation.",
"paused": "This survey is temporarily paused.",
"paused_heading": "Paused",
"please_try_again_with_the_original_link": "Please try again with the original link",
"preview_survey_questions": "Preview survey questions.",
"question_preview": "Question Preview",
@@ -2447,9 +2450,7 @@
"verify_email_before_submission": "Verify your email to respond",
"verify_email_before_submission_button": "Verify",
"verify_email_before_submission_description": "To respond to this survey, please verify your email",
"want_to_respond": "Want to respond?",
"paused_heading": "Paused",
"completed_heading": "Completed"
"want_to_respond": "Want to respond?"
},
"setup": {
"intro": {
+4 -3
View File
@@ -2212,6 +2212,7 @@
"custom_scripts_warning": "Los scripts se ejecutan con acceso completo al navegador. Solo añade scripts de fuentes confiables.",
"delete_workspace": "Eliminar proyecto",
"delete_workspace_confirmation": "¿Estás seguro de que quieres eliminar {projectName}? Esta acción no se puede deshacer.",
"delete_workspace_confirmation_name": "Por favor, introduce {projectName} en el siguiente campo para confirmar la eliminación definitiva de este proyecto:",
"delete_workspace_name_includes_surveys_responses_people_and_more": "Eliminar {projectName} incluyendo todas las encuestas, respuestas, personas, acciones y atributos.",
"delete_workspace_settings_description": "Eliminar proyecto con todas las encuestas, respuestas, personas, acciones y atributos. Esto no se puede deshacer.",
"error_saving_workspace_information": "Error al guardar la información del proyecto",
@@ -2429,11 +2430,13 @@
"s": {
"check_inbox_or_spam": "Por favor, comprueba también tu carpeta de spam si no ves el correo electrónico en tu bandeja de entrada.",
"completed": "Esta encuesta está cerrada.",
"completed_heading": "Completado",
"create_your_own": "Crea tu propia encuesta de código abierto",
"enter_pin": "Esta encuesta está protegida. Introduce el PIN a continuación.",
"just_curious": "¿Solo tienes curiosidad?",
"link_invalid": "Esta encuesta solo se puede realizar por invitación.",
"paused": "Esta encuesta está temporalmente pausada.",
"paused_heading": "Pausado",
"please_try_again_with_the_original_link": "Por favor, inténtalo de nuevo con el enlace original",
"preview_survey_questions": "Vista previa de las preguntas de la encuesta.",
"question_preview": "Vista previa de la pregunta",
@@ -2447,9 +2450,7 @@
"verify_email_before_submission": "Verifica tu correo electrónico para responder",
"verify_email_before_submission_button": "Verificar",
"verify_email_before_submission_description": "Para responder a esta encuesta, por favor verifica tu correo electrónico",
"want_to_respond": "¿Quieres responder?",
"paused_heading": "Pausado",
"completed_heading": "Completado"
"want_to_respond": "¿Quieres responder?"
},
"setup": {
"intro": {
+4 -3
View File
@@ -2212,6 +2212,7 @@
"custom_scripts_warning": "Les scripts s'exécutent avec un accès complet au navigateur. Ajoutez uniquement des scripts provenant de sources fiables.",
"delete_workspace": "Supprimer le projet",
"delete_workspace_confirmation": "Êtes-vous sûr de vouloir supprimer {projectName}? Cette action ne peut pas être annulée.",
"delete_workspace_confirmation_name": "Veuillez entrer {projectName} dans le champ suivant pour confirmer la suppression définitive de ce projet :",
"delete_workspace_name_includes_surveys_responses_people_and_more": "Supprimer {projectName} y compris toutes les enquêtes, réponses, personnes, actions et attributs.",
"delete_workspace_settings_description": "Supprimer le projet avec toutes les enquêtes, réponses, personnes, actions et attributs. Cette opération est irréversible.",
"error_saving_workspace_information": "Erreur lors de l'enregistrement des informations du projet",
@@ -2429,11 +2430,13 @@
"s": {
"check_inbox_or_spam": "Veuillez également vérifier votre dossier de spam si vous ne voyez pas l'e-mail dans votre boîte de réception.",
"completed": "Cette enquête gratuite et open-source a été fermée.",
"completed_heading": "Terminé",
"create_your_own": "Créez le vôtre",
"enter_pin": "Ce sondage est protégé. Entrez le code PIN ci-dessous.",
"just_curious": "Juste curieux ?",
"link_invalid": "Cette enquête ne peut être réalisée que sur invitation.",
"paused": "Cette enquête gratuite et open-source est temporairement suspendue.",
"paused_heading": "En pause",
"please_try_again_with_the_original_link": "Veuillez réessayer avec le lien d'origine.",
"preview_survey_questions": "Aperçu des questions de l'enquête.",
"question_preview": "Aperçu de la question",
@@ -2447,9 +2450,7 @@
"verify_email_before_submission": "Vérifiez votre email pour répondre.",
"verify_email_before_submission_button": "Vérifier",
"verify_email_before_submission_description": "Pour répondre à cette enquête, veuillez vérifier votre e-mail.",
"want_to_respond": "Voulez-vous répondre ?",
"paused_heading": "En pause",
"completed_heading": "Terminé"
"want_to_respond": "Voulez-vous répondre ?"
},
"setup": {
"intro": {
+4 -3
View File
@@ -2212,6 +2212,7 @@
"custom_scripts_warning": "A parancsfájlok teljes böngésző-hozzáféréssel kerülnek végrehajtásra. Csak megbízható forrásokból származó parancsfájlokat adjon hozzá.",
"delete_workspace": "Munkaterület törlése",
"delete_workspace_confirmation": "Biztosan törölni szeretné a(z) {projectName} munkaterületet? Ezt a műveletet nem lehet visszavonni.",
"delete_workspace_confirmation_name": "Adja meg a(z) {projectName} munkaterület nevét a következő mezőben a munkaterület végleges törlésének megerősítéséhez:",
"delete_workspace_name_includes_surveys_responses_people_and_more": "A(z) {projectName} munkaterület törlése, beleértve az összes kérdőívet, választ, személyt, műveletet és attribútumot is.",
"delete_workspace_settings_description": "A munkaterület törlése az összes kérdőívvel, válasszal, személlyel, művelettel és attribútummal együtt. Ezt nem lehet visszavonni.",
"error_saving_workspace_information": "Hiba a munkaterület-információk mentésekor",
@@ -2429,11 +2430,13 @@
"s": {
"check_inbox_or_spam": "Nézze meg a levélszemét mappát is, ha nem találja az e-mailt a beérkező levelek között.",
"completed": "Ez a kérdőív le van zárva.",
"completed_heading": "Befejezve",
"create_your_own": "Saját nyílt forráskódú kérdőív létrehozása",
"enter_pin": "Ez a kérdőív védett. Adja meg a PIN-kódot lent.",
"just_curious": "Csak kíváncsi?",
"link_invalid": "Ez a kérdőív csak meghívás útján tölthető ki.",
"paused": "Ez a kérdőív átmenetileg szüneteltetve van.",
"paused_heading": "Szüneteltetve",
"please_try_again_with_the_original_link": "Próbálja meg újra az eredeti hivatkozással",
"preview_survey_questions": "Kérdőív kérdéseinek előnézete.",
"question_preview": "Kérdés előnézete",
@@ -2447,9 +2450,7 @@
"verify_email_before_submission": "Ellenőrizze az e-mail-címét a válaszadáshoz",
"verify_email_before_submission_button": "Ellenőrzés",
"verify_email_before_submission_description": "A kérdőívre való válaszadáshoz ellenőrizze az e-mail-címét",
"want_to_respond": "Szeretne válaszolni?",
"paused_heading": "Szüneteltetve",
"completed_heading": "Befejezve"
"want_to_respond": "Szeretne válaszolni?"
},
"setup": {
"intro": {
+4 -3
View File
@@ -2212,6 +2212,7 @@
"custom_scripts_warning": "スクリプトはブラウザへの完全なアクセス権で実行されます。信頼できるソースからのスクリプトのみを追加してください。",
"delete_workspace": "ワークスペースを削除",
"delete_workspace_confirmation": "{projectName}を削除してもよろしいですか?このアクションは元に戻せません。",
"delete_workspace_confirmation_name": "このワークスペースの完全な削除を確認するには、以下のフィールドに {projectName} と入力してください:",
"delete_workspace_name_includes_surveys_responses_people_and_more": "{projectName}をすべてのフォーム、回答、人物、アクション、属性を含めて削除します。",
"delete_workspace_settings_description": "すべてのフォーム、回答、人物、アクション、属性を含むワークスペースを削除します。この操作は元に戻せません。",
"error_saving_workspace_information": "ワークスペース情報の保存中にエラーが発生しました",
@@ -2429,11 +2430,13 @@
"s": {
"check_inbox_or_spam": "受信トレイにメールがない場合は、迷惑メールフォルダも確認してください。",
"completed": "このフォームはクローズしました。",
"completed_heading": "完了",
"create_your_own": "独自のオープンソースフォームを作成",
"enter_pin": "このアンケートは保護されています。以下にPINを入力してください。",
"just_curious": "ただ興味があるだけですか?",
"link_invalid": "このフォームは招待によってのみ回答できます。",
"paused": "このフォームは一時的に一時停止されています。",
"paused_heading": "一時停止",
"please_try_again_with_the_original_link": "元のリンクでもう一度お試しください",
"preview_survey_questions": "フォームの質問をプレビュー。",
"question_preview": "質問プレビュー",
@@ -2447,9 +2450,7 @@
"verify_email_before_submission": "回答するにはメールアドレスを認証してください",
"verify_email_before_submission_button": "認証",
"verify_email_before_submission_description": "このフォームに回答するには、メールアドレスを認証してください",
"want_to_respond": "回答しますか?",
"paused_heading": "一時停止",
"completed_heading": "完了"
"want_to_respond": "回答しますか?"
},
"setup": {
"intro": {
+4 -3
View File
@@ -2212,6 +2212,7 @@
"custom_scripts_warning": "Scripts worden uitgevoerd met volledige browsertoegang. Voeg alleen scripts toe van vertrouwde bronnen.",
"delete_workspace": "Project verwijderen",
"delete_workspace_confirmation": "Weet u zeker dat u {projectName} wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
"delete_workspace_confirmation_name": "Voer {projectName} in het volgende veld in om de definitieve verwijdering van dit project te bevestigen:",
"delete_workspace_name_includes_surveys_responses_people_and_more": "Verwijder {projectName} incl. alle enquêtes, reacties, mensen, acties en attributen.",
"delete_workspace_settings_description": "Verwijder project met alle enquêtes, reacties, mensen, acties en attributen. Dit kan niet ongedaan worden gemaakt.",
"error_saving_workspace_information": "Fout bij opslaan van projectinformatie",
@@ -2429,11 +2430,13 @@
"s": {
"check_inbox_or_spam": "Controleer ook uw spammap als u de e-mail niet in uw inbox ziet.",
"completed": "Deze enquête is gesloten.",
"completed_heading": "Voltooid",
"create_your_own": "Creëer uw eigen open source-enquête",
"enter_pin": "Deze enquête is beveiligd. Voer hieronder de pincode in.",
"just_curious": "Gewoon nieuwsgierig?",
"link_invalid": "Aan deze enquête kan alleen op uitnodiging worden deelgenomen.",
"paused": "Deze enquête is tijdelijk onderbroken.",
"paused_heading": "Gepauzeerd",
"please_try_again_with_the_original_link": "Probeer het opnieuw met de originele link",
"preview_survey_questions": "Bekijk enquêtevragen.",
"question_preview": "Vraagvoorbeeld",
@@ -2447,9 +2450,7 @@
"verify_email_before_submission": "Verifieer uw e-mailadres om te reageren",
"verify_email_before_submission_button": "Verifiëren",
"verify_email_before_submission_description": "Om op deze enquête te reageren, dient u uw e-mailadres te verifiëren",
"want_to_respond": "Wilt u reageren?",
"paused_heading": "Gepauzeerd",
"completed_heading": "Voltooid"
"want_to_respond": "Wilt u reageren?"
},
"setup": {
"intro": {
+4 -3
View File
@@ -2212,6 +2212,7 @@
"custom_scripts_warning": "Os scripts são executados com acesso total ao navegador. Adicione apenas scripts de fontes confiáveis.",
"delete_workspace": "Excluir projeto",
"delete_workspace_confirmation": "Tem certeza de que deseja excluir {projectName}? Essa ação não pode ser desfeita.",
"delete_workspace_confirmation_name": "Por favor, insira {projectName} no campo abaixo para confirmar a exclusão definitiva deste projeto:",
"delete_workspace_name_includes_surveys_responses_people_and_more": "Excluir {projectName} incluindo todas as pesquisas, respostas, pessoas, ações e atributos.",
"delete_workspace_settings_description": "Excluir projeto com todas as pesquisas, respostas, pessoas, ações e atributos. Isso não pode ser desfeito.",
"error_saving_workspace_information": "Erro ao salvar informações do projeto",
@@ -2429,11 +2430,13 @@
"s": {
"check_inbox_or_spam": "Por favor, dá uma olhada na sua pasta de spam se você não encontrar o e-mail na sua caixa de entrada.",
"completed": "Essa pesquisa gratuita e de código aberto foi encerrada.",
"completed_heading": "Concluído",
"create_your_own": "Crie o seu próprio",
"enter_pin": "Esta pesquisa está protegida. Digite o PIN abaixo.",
"just_curious": "Só curioso?",
"link_invalid": "Essa pesquisa só pode ser respondida por convite.",
"paused": "Essa pesquisa gratuita e de código aberto está temporariamente pausada.",
"paused_heading": "Pausado",
"please_try_again_with_the_original_link": "Por favor, tente novamente com o link original",
"preview_survey_questions": "Visualizar perguntas da pesquisa.",
"question_preview": "Prévia da Pergunta",
@@ -2447,9 +2450,7 @@
"verify_email_before_submission": "Verifique seu e-mail para responder",
"verify_email_before_submission_button": "Verificar",
"verify_email_before_submission_description": "Para responder a esta pesquisa, confirme seu e-mail",
"want_to_respond": "Quer responder?",
"paused_heading": "Pausado",
"completed_heading": "Concluído"
"want_to_respond": "Quer responder?"
},
"setup": {
"intro": {
+4 -3
View File
@@ -2212,6 +2212,7 @@
"custom_scripts_warning": "Os scripts são executados com acesso total ao navegador. Adicione apenas scripts de fontes fidedignas.",
"delete_workspace": "Eliminar projeto",
"delete_workspace_confirmation": "Tem a certeza de que pretende eliminar {projectName}? Esta ação não pode ser desfeita.",
"delete_workspace_confirmation_name": "Por favor, insira {projectName} no campo seguinte para confirmar a eliminação definitiva deste projeto:",
"delete_workspace_name_includes_surveys_responses_people_and_more": "Eliminar {projectName} incluindo todos os inquéritos, respostas, pessoas, ações e atributos.",
"delete_workspace_settings_description": "Eliminar projeto com todos os inquéritos, respostas, pessoas, ações e atributos. Isto não pode ser desfeito.",
"error_saving_workspace_information": "Erro ao guardar informações do projeto",
@@ -2429,11 +2430,13 @@
"s": {
"check_inbox_or_spam": "Por favor, verifique também a sua pasta de spam se não vir o email na sua caixa de entrada.",
"completed": "Este inquérito está encerrado.",
"completed_heading": "Concluído",
"create_your_own": "Crie o seu próprio inquérito de código aberto",
"enter_pin": "Este inquérito está protegido. Introduza o PIN abaixo.",
"just_curious": "Só por curiosidade?",
"link_invalid": "Este inquérito só pode ser respondido por convite.",
"paused": "Este inquérito está temporariamente suspenso.",
"paused_heading": "Em pausa",
"please_try_again_with_the_original_link": "Por favor, tente novamente com o link original",
"preview_survey_questions": "Pré-visualizar perguntas do inquérito.",
"question_preview": "Pré-visualização da Pergunta",
@@ -2447,9 +2450,7 @@
"verify_email_before_submission": "Verifique o seu email para responder",
"verify_email_before_submission_button": "Verificar",
"verify_email_before_submission_description": "Para responder a este questionário, por favor verifique o seu email",
"want_to_respond": "Quer responder?",
"paused_heading": "Em pausa",
"completed_heading": "Concluído"
"want_to_respond": "Quer responder?"
},
"setup": {
"intro": {
+4 -3
View File
@@ -2212,6 +2212,7 @@
"custom_scripts_warning": "Scripturile se execută cu acces complet la browser. Adaugă doar scripturi din surse de încredere.",
"delete_workspace": "Șterge proiectul",
"delete_workspace_confirmation": "Sigur vrei să ștergi {projectName}? Această acțiune nu poate fi anulată.",
"delete_workspace_confirmation_name": "Vă rugăm să introduceți {projectName} în câmpul următor pentru a confirma ștergerea definitivă a acestui proiect:",
"delete_workspace_name_includes_surveys_responses_people_and_more": "Șterge {projectName} incl. toate sondajele, răspunsurile, persoanele, acțiunile și atributele.",
"delete_workspace_settings_description": "Șterge proiectul cu toate sondajele, răspunsurile, persoanele, acțiunile și atributele. Aceasta nu poate fi anulată.",
"error_saving_workspace_information": "Eroare la salvarea informațiilor despre proiect",
@@ -2429,11 +2430,13 @@
"s": {
"check_inbox_or_spam": "Vă rugăm să verificați și folderul de spam dacă nu vedeți emailul în inbox.",
"completed": "Acest chestionar este închis.",
"completed_heading": "Completat",
"create_your_own": "Creează-ți propriul chestionar open-source",
"enter_pin": "Acest sondaj este protejat. Introduceți PIN-ul mai jos.",
"just_curious": "Doar curios?",
"link_invalid": "Acest sondaj poate fi completat doar pe bază de invitație.",
"paused": "Acest sondaj este temporar întrerupt.",
"paused_heading": "Pauză",
"please_try_again_with_the_original_link": "Vă rugăm să încercați din nou cu linkul original",
"preview_survey_questions": "Previzualizare întrebări chestionar",
"question_preview": "Previzualizare Întrebare",
@@ -2447,9 +2450,7 @@
"verify_email_before_submission": "Verificați-vă emailul pentru a răspunde",
"verify_email_before_submission_button": "Verifică",
"verify_email_before_submission_description": "Pentru a răspunde la acest sondaj, vă rugăm să vă verificați emailul",
"want_to_respond": "Dorești să răspunzi?",
"paused_heading": "Pauză",
"completed_heading": "Completat"
"want_to_respond": "Dorești să răspunzi?"
},
"setup": {
"intro": {
+4 -3
View File
@@ -2212,6 +2212,7 @@
"custom_scripts_warning": "Скрипты выполняются с полным доступом к браузеру. Добавляйте только скрипты из доверенных источников.",
"delete_workspace": "Удалить рабочий проект",
"delete_workspace_confirmation": "Вы уверены, что хотите удалить {projectName}? Это действие необратимо.",
"delete_workspace_confirmation_name": "Пожалуйста, введите {projectName} в поле ниже для подтверждения окончательного удаления этого рабочего проекта:",
"delete_workspace_name_includes_surveys_responses_people_and_more": "Удалить {projectName} вместе со всеми опросами, ответами, пользователями, действиями и атрибутами.",
"delete_workspace_settings_description": "Удалить рабочий проект со всеми опросами, ответами, пользователями, действиями и атрибутами. Это действие необратимо.",
"error_saving_workspace_information": "Ошибка при сохранении информации о рабочем проекте",
@@ -2429,11 +2430,13 @@
"s": {
"check_inbox_or_spam": "Если вы не видите письмо во входящих, проверьте папку со спамом.",
"completed": "Этот опрос закрыт.",
"completed_heading": "Завершено",
"create_your_own": "Создайте свой собственный опрос с открытым исходным кодом",
"enter_pin": "Этот опрос защищён. Введите PIN ниже.",
"just_curious": "Просто интересно?",
"link_invalid": "Пройти этот опрос можно только по приглашению.",
"paused": "Этот опрос временно приостановлен.",
"paused_heading": "Приостановлено",
"please_try_again_with_the_original_link": "Пожалуйста, попробуйте снова, используя оригинальную ссылку",
"preview_survey_questions": "Просмотреть вопросы опроса.",
"question_preview": "Предпросмотр вопроса",
@@ -2447,9 +2450,7 @@
"verify_email_before_submission": "Подтвердите свой email, чтобы ответить",
"verify_email_before_submission_button": "Подтвердить",
"verify_email_before_submission_description": "Чтобы ответить на этот опрос, пожалуйста, подтвердите свой email",
"want_to_respond": "Хотите ответить?",
"paused_heading": "Приостановлено",
"completed_heading": "Завершено"
"want_to_respond": "Хотите ответить?"
},
"setup": {
"intro": {
+4 -3
View File
@@ -2212,6 +2212,7 @@
"custom_scripts_warning": "Skript körs med full åtkomst till webbläsaren. Lägg endast till skript från betrodda källor.",
"delete_workspace": "Ta bort arbetsyta",
"delete_workspace_confirmation": "Är du säker på att du vill ta bort {projectName}? Denna åtgärd kan inte ångras.",
"delete_workspace_confirmation_name": "Vänligen ange {projectName} i följande fält för att bekräfta den definitiva borttagningen av denna arbetsyta:",
"delete_workspace_name_includes_surveys_responses_people_and_more": "Ta bort {projectName} inkl. alla enkäter, svar, personer, åtgärder och attribut.",
"delete_workspace_settings_description": "Ta bort arbetsyta med alla enkäter, svar, personer, åtgärder och attribut. Detta kan inte ångras.",
"error_saving_workspace_information": "Fel vid sparande av arbetsytans information",
@@ -2429,11 +2430,13 @@
"s": {
"check_inbox_or_spam": "Vänligen kontrollera även din skräppost om du inte ser e-postmeddelandet i din inkorg.",
"completed": "Denna enkät är stängd.",
"completed_heading": "Slutförd",
"create_your_own": "Skapa din egen öppenkällkodsenkät",
"enter_pin": "Denna undersökning är skyddad. Ange PIN-koden nedan.",
"just_curious": "Bara nyfiken?",
"link_invalid": "Denna enkät kan endast tas via inbjudan.",
"paused": "Denna enkät är tillfälligt pausad.",
"paused_heading": "Pausad",
"please_try_again_with_the_original_link": "Vänligen försök igen med den ursprungliga länken",
"preview_survey_questions": "Förhandsgranska enkätfrågor.",
"question_preview": "Frågeförhandsgranskning",
@@ -2447,9 +2450,7 @@
"verify_email_before_submission": "Verifiera din e-post för att svara",
"verify_email_before_submission_button": "Verifiera",
"verify_email_before_submission_description": "För att svara på denna enkät, vänligen verifiera din e-post",
"want_to_respond": "Vill du svara?",
"paused_heading": "Pausad",
"completed_heading": "Slutförd"
"want_to_respond": "Vill du svara?"
},
"setup": {
"intro": {
+4 -3
View File
@@ -2212,6 +2212,7 @@
"custom_scripts_warning": "Betikler tam tarayıcı erişimiyle çalışır. Yalnızca güvenilir kaynaklardan betik ekleyin.",
"delete_workspace": "Çalışma Alanını Sil",
"delete_workspace_confirmation": "{projectName} öğesini silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.",
"delete_workspace_confirmation_name": "Bu çalışma alanının kesin olarak silinmesini onaylamak için lütfen aşağıdaki alana {projectName} yazın:",
"delete_workspace_name_includes_surveys_responses_people_and_more": "Tüm survey, yanıt, kişi, eylem ve nitelikleri dahil {projectName} öğesini silin.",
"delete_workspace_settings_description": "Tüm survey, yanıt, kişi, eylem ve nitelikleriyle birlikte çalışma alanını silin. Bu işlem geri alınamaz.",
"error_saving_workspace_information": "Çalışma alanı bilgileri kaydedilirken hata oluştu",
@@ -2429,11 +2430,13 @@
"s": {
"check_inbox_or_spam": "Email'i gelen kutunuzda görmüyorsanız lütfen spam klasörünüzü de kontrol edin.",
"completed": "Bu survey kapatılmıştır.",
"completed_heading": "Tamamlandı",
"create_your_own": "Kendi açık kaynak survey'inizi oluşturun",
"enter_pin": "Bu survey korumalıdır. Aşağıya PIN girin.",
"just_curious": "Sadece merak mı ediyorsunuz?",
"link_invalid": "Bu survey yalnızca davet ile katılıma açıktır.",
"paused": "Bu survey geçici olarak duraklatılmıştır.",
"paused_heading": "Duraklatıldı",
"please_try_again_with_the_original_link": "Lütfen orijinal bağlantıyla tekrar deneyin",
"preview_survey_questions": "Survey sorularını önizleyin.",
"question_preview": "Soru Önizlemesi",
@@ -2447,9 +2450,7 @@
"verify_email_before_submission": "Yanıtlamak için email adresinizi doğrulayın",
"verify_email_before_submission_button": "Doğrula",
"verify_email_before_submission_description": "Bu survey'e yanıt vermek için lütfen email adresinizi doğrulayın",
"want_to_respond": "Yanıtlamak ister misiniz?",
"paused_heading": "Duraklatıldı",
"completed_heading": "Tamamlandı"
"want_to_respond": "Yanıtlamak ister misiniz?"
},
"setup": {
"intro": {
+4 -3
View File
@@ -2212,6 +2212,7 @@
"custom_scripts_warning": "脚本将以完整浏览器权限执行。请仅添加来自可信来源的脚本。",
"delete_workspace": "删除工作区",
"delete_workspace_confirmation": "您确定要删除 {projectName} 吗?此操作无法撤销。",
"delete_workspace_confirmation_name": "请在下列字段中输入 {projectName} 以确认永久删除此工作区:",
"delete_workspace_name_includes_surveys_responses_people_and_more": "删除 {projectName},包括所有调查、回应、人员、动作和属性。",
"delete_workspace_settings_description": "删除工作区及其所有调查、回应、人员、动作和属性。此操作无法撤销。",
"error_saving_workspace_information": "保存工作区信息时出错",
@@ -2429,11 +2430,13 @@
"s": {
"check_inbox_or_spam": "请 也 检查 您 的 垃圾邮件 文件夹 如果 您 没有 在 收件箱 中 看到 邮件。",
"completed": "此 调查 关闭",
"completed_heading": "完成",
"create_your_own": "创建 你 的 开源 调查",
"enter_pin": "本调查已受保护。请在下方输入PIN码。",
"just_curious": "只是好奇 ",
"link_invalid": "此调查只能通过邀请参加。",
"paused": "此调查暂时暂停。",
"paused_heading": "暂停",
"please_try_again_with_the_original_link": "请 尝试 使用 原始 链接",
"preview_survey_questions": "预览 问卷调查 问题。",
"question_preview": "问题 预览",
@@ -2447,9 +2450,7 @@
"verify_email_before_submission": "验证 您的 邮件 以 响应",
"verify_email_before_submission_button": "验证",
"verify_email_before_submission_description": "要 响应 此 调查,请 验证 您的 邮件",
"want_to_respond": "想要 参与 吗?",
"paused_heading": "暂停",
"completed_heading": "完成"
"want_to_respond": "想要 参与 吗?"
},
"setup": {
"intro": {
+4 -3
View File
@@ -2212,6 +2212,7 @@
"custom_scripts_warning": "腳本將以完整瀏覽器權限執行。請僅加入來自可信來源的腳本。",
"delete_workspace": "刪除工作區",
"delete_workspace_confirmation": "您確定要刪除 {projectName} 嗎?此操作無法復原。",
"delete_workspace_confirmation_name": "請在下列欄位中輸入 {projectName} 以確認永久刪除此工作區:",
"delete_workspace_name_includes_surveys_responses_people_and_more": "刪除 {projectName}(包含所有問卷、回應、人員、操作和屬性)。",
"delete_workspace_settings_description": "刪除工作區及其所有問卷、回應、人員、操作和屬性。此操作無法復原。",
"error_saving_workspace_information": "儲存工作區資訊時發生錯誤",
@@ -2429,11 +2430,13 @@
"s": {
"check_inbox_or_spam": "如果您的收件匣中沒有看到電子郵件,也請檢查您的垃圾郵件資料夾。",
"completed": "此免費且開源的問卷已關閉。",
"completed_heading": "已完成",
"create_your_own": "建立您自己的",
"enter_pin": "此問卷已受保護。請在下方輸入 PIN 碼。",
"just_curious": "只是好奇?",
"link_invalid": "此問卷只能透過邀請填寫。",
"paused": "此免費且開源的問卷已暫時暫停。",
"paused_heading": "已暫停",
"please_try_again_with_the_original_link": "請使用原始連結再試一次",
"preview_survey_questions": "預覽問卷問題。",
"question_preview": "問題預覽",
@@ -2447,9 +2450,7 @@
"verify_email_before_submission": "驗證您的電子郵件以回應",
"verify_email_before_submission_button": "驗證",
"verify_email_before_submission_description": "若要回應此問卷,請驗證您的電子郵件",
"want_to_respond": "想要回應嗎?",
"paused_heading": "已暫停",
"completed_heading": "已完成"
"want_to_respond": "想要回應嗎?"
},
"setup": {
"intro": {
@@ -0,0 +1,111 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { InvalidInputError } from "@formbricks/types/errors";
import { deleteProjectAction } from "./actions";
const mocks = vi.hoisted(() => ({
deleteProjectWithConfirmation: vi.fn(),
getProjectIdForLogging: vi.fn(),
loggerError: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: mocks.loggerError,
},
}));
vi.mock("@/lib/utils/action-client", () => ({
authenticatedActionClient: {
inputSchema: vi.fn(() => ({
action: vi.fn((fn) => fn),
})),
},
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
withAuditLogging: vi.fn((_eventName, _objectType, fn) => fn),
}));
vi.mock("./lib/delete-project", () => ({
deleteProjectWithConfirmation: mocks.deleteProjectWithConfirmation,
getProjectIdForLogging: mocks.getProjectIdForLogging,
}));
const baseProject = {
id: "cmproject000000000000000000",
name: "Acme Workspace",
organizationId: "cmorg00000000000000000000",
};
const ctx = {
user: {
id: "cmuser00000000000000000000",
},
auditLoggingCtx: {},
};
describe("deleteProjectAction", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getProjectIdForLogging.mockReturnValue(baseProject.id);
mocks.deleteProjectWithConfirmation.mockResolvedValue(baseProject);
});
test("delegates workspace deletion to the covered lib", async () => {
const parsedInput = {
projectId: baseProject.id,
confirmationName: "acme workspace",
};
const result = await deleteProjectAction({
ctx,
parsedInput,
} as any);
expect(mocks.getProjectIdForLogging).toHaveBeenCalledWith(parsedInput);
expect(mocks.deleteProjectWithConfirmation).toHaveBeenCalledWith({
input: parsedInput,
userId: ctx.user.id,
auditLoggingCtx: ctx.auditLoggingCtx,
});
expect(result).toEqual(baseProject);
});
test("logs and rethrows deletion failures", async () => {
const error = new Error("delete failed");
mocks.deleteProjectWithConfirmation.mockRejectedValueOnce(error);
await expect(
deleteProjectAction({
ctx,
parsedInput: {
projectId: baseProject.id,
confirmationName: baseProject.name,
},
} as any)
).rejects.toThrow(error);
expect(logger.error).toHaveBeenCalledWith(
{ error, userId: ctx.user.id, projectId: baseProject.id },
"Workspace deletion failed"
);
});
test("does not error-log expected deletion failures", async () => {
const error = new InvalidInputError("Workspace name confirmation does not match");
mocks.deleteProjectWithConfirmation.mockRejectedValueOnce(error);
await expect(
deleteProjectAction({
ctx,
parsedInput: {
projectId: baseProject.id,
confirmationName: "Other Workspace",
},
} as any)
).rejects.toThrow(error);
expect(logger.error).not.toHaveBeenCalled();
});
});
@@ -1,44 +1,35 @@
"use server";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { getProject, getUserProjects } from "@/lib/project/service";
import { logger } from "@formbricks/logger";
import { isExpectedError } from "@formbricks/types/errors";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromProjectId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { deleteProject } from "@/modules/projects/settings/lib/project";
import { deleteProjectWithConfirmation, getProjectIdForLogging } from "./lib/delete-project";
const ZProjectDeleteAction = z.object({
projectId: ZId,
});
const logProjectDeletionError = (userId: string, projectId: string, error: unknown) => {
logger.error({ error, userId, projectId }, "Workspace deletion failed");
};
export const deleteProjectAction = authenticatedActionClient.inputSchema(ZProjectDeleteAction).action(
const shouldLogProjectDeletionError = (error: unknown) => {
return !(error instanceof Error && isExpectedError(error));
};
export const deleteProjectAction = authenticatedActionClient.inputSchema(z.unknown()).action(
withAuditLogging("deleted", "project", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromProjectId(parsedInput.projectId);
const projectIdForLogging = getProjectIdForLogging(parsedInput);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
],
});
const availableProjects = (await getUserProjects(ctx.user.id, organizationId)) ?? null;
if (!!availableProjects && availableProjects?.length <= 1) {
throw new Error("You can't delete the last project in the environment.");
try {
return await deleteProjectWithConfirmation({
input: parsedInput,
userId: ctx.user.id,
auditLoggingCtx: ctx.auditLoggingCtx,
});
} catch (error) {
if (shouldLogProjectDeletionError(error)) {
logProjectDeletionError(ctx.user.id, projectIdForLogging, error);
}
throw error;
}
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = parsedInput.projectId;
ctx.auditLoggingCtx.oldObject = await getProject(parsedInput.projectId);
// delete project
return await deleteProject(parsedInput.projectId);
})
);
@@ -4,14 +4,17 @@ import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { logger } from "@formbricks/logger";
import { TProject } from "@formbricks/types/project";
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { truncate } from "@/lib/utils/strings";
import { deleteProjectAction } from "@/modules/projects/settings/general/actions";
import { hasMatchingWorkspaceDeleteConfirmation } from "@/modules/projects/settings/general/lib/delete-project-confirmation";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { Input } from "@/modules/ui/components/input";
interface DeleteProjectRenderProps {
isDeleteDisabled: boolean;
@@ -30,30 +33,55 @@ export const DeleteProjectRender = ({
const router = useRouter();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const handleDeleteProject = async () => {
setIsDeleting(true);
const deleteProjectResponse = await deleteProjectAction({ projectId: currentProject.id });
if (deleteProjectResponse?.data) {
if (organizationProjects.length === 1) {
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
} else if (organizationProjects.length > 1) {
// prevents changing of organization when deleting project
const remainingProjects = organizationProjects.filter((project) => project.id !== currentProject.id);
const productionEnvironment = remainingProjects[0].environments.find(
(environment) => environment.type === "production"
);
if (productionEnvironment) {
localStorage.setItem(FORMBRICKS_ENVIRONMENT_ID_LS, productionEnvironment.id);
}
}
toast.success(t("environments.workspace.general.workspace_deleted_successfully"));
router.push("/");
} else {
const errorMessage = getFormattedErrorMessage(deleteProjectResponse);
toast.error(errorMessage);
setIsDeleteDialogOpen(false);
const [confirmationName, setConfirmationName] = useState("");
const hasValidConfirmation = hasMatchingWorkspaceDeleteConfirmation(confirmationName, currentProject.name);
const handleDeleteDialogOpenChange = (open: boolean) => {
if (!open) {
setConfirmationName("");
}
setIsDeleteDialogOpen(open);
};
const handleDeleteProject = async () => {
if (!hasValidConfirmation) {
return;
}
try {
setIsDeleting(true);
const deleteProjectResponse = await deleteProjectAction({
projectId: currentProject.id,
confirmationName,
});
if (deleteProjectResponse?.data) {
if (organizationProjects.length === 1) {
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
} else if (organizationProjects.length > 1) {
// prevents changing of organization when deleting project
const remainingProject = organizationProjects.find((project) => project.id !== currentProject.id);
const productionEnvironment = remainingProject?.environments.find(
(environment) => environment.type === "production"
);
if (productionEnvironment) {
localStorage.setItem(FORMBRICKS_ENVIRONMENT_ID_LS, productionEnvironment.id);
}
}
toast.success(t("environments.workspace.general.workspace_deleted_successfully"));
router.push("/");
} else {
const errorMessage = getFormattedErrorMessage(deleteProjectResponse);
logger.error({ errorMessage, projectId: currentProject.id }, "Workspace deletion action failed");
toast.error(errorMessage);
handleDeleteDialogOpenChange(false);
}
} catch (error) {
logger.error({ error, projectId: currentProject.id }, "Workspace deletion failed");
toast.error(t("common.something_went_wrong_please_try_again"));
} finally {
setIsDeleting(false);
}
setIsDeleting(false);
};
return (
@@ -91,13 +119,36 @@ export const DeleteProjectRender = ({
<DeleteDialog
deleteWhat={t("environments.settings.domain.workspace")}
open={isDeleteDialogOpen}
setOpen={setIsDeleteDialogOpen}
setOpen={handleDeleteDialogOpenChange}
onDelete={handleDeleteProject}
text={t("environments.workspace.general.delete_workspace_confirmation", {
projectName: truncate(currentProject.name, 30),
})}
isDeleting={isDeleting}
/>
disabled={!hasValidConfirmation}>
<div className="py-5">
<form
onSubmit={async (e) => {
e.preventDefault();
await handleDeleteProject();
}}>
<label htmlFor="deleteProjectConfirmation">
{t("environments.workspace.general.delete_workspace_confirmation_name", {
projectName: currentProject.name,
})}
</label>
<Input
value={confirmationName}
onChange={(e) => setConfirmationName(e.target.value)}
placeholder={currentProject.name}
className="mt-2"
type="text"
id="deleteProjectConfirmation"
name="deleteProjectConfirmation"
/>
</form>
</div>
</DeleteDialog>
</div>
);
};
@@ -0,0 +1,24 @@
import { describe, expect, test } from "vitest";
import { hasMatchingWorkspaceDeleteConfirmation } from "./delete-project-confirmation";
describe("workspace delete confirmation", () => {
test("accepts an exact workspace name match", () => {
expect(hasMatchingWorkspaceDeleteConfirmation("Acme Workspace", "Acme Workspace")).toBe(true);
});
test("accepts different casing", () => {
expect(hasMatchingWorkspaceDeleteConfirmation("acme workspace", "Acme Workspace")).toBe(true);
});
test("accepts leading and trailing whitespace", () => {
expect(hasMatchingWorkspaceDeleteConfirmation(" Acme Workspace ", "Acme Workspace")).toBe(true);
});
test("rejects an empty confirmation", () => {
expect(hasMatchingWorkspaceDeleteConfirmation("", "Acme Workspace")).toBe(false);
});
test("rejects mismatched confirmations", () => {
expect(hasMatchingWorkspaceDeleteConfirmation("Other Workspace", "Acme Workspace")).toBe(false);
});
});
@@ -0,0 +1,12 @@
export const WORKSPACE_DELETE_CONFIRMATION_ERROR = "Workspace name confirmation does not match";
const normalizeWorkspaceNameConfirmation = (value: string) => value.trim().toLowerCase();
export const hasMatchingWorkspaceDeleteConfirmation = (
confirmationName: string,
workspaceName: string
): boolean => {
return (
normalizeWorkspaceNameConfirmation(confirmationName) === normalizeWorkspaceNameConfirmation(workspaceName)
);
};
@@ -0,0 +1,170 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import {
AuthorizationError,
InvalidInputError,
OperationNotAllowedError,
ResourceNotFoundError,
} from "@formbricks/types/errors";
import {
DELETE_PROJECT_CONFIRMATION_REQUIRED_ERROR,
deleteProjectWithConfirmation,
getProjectIdForLogging,
} from "./delete-project";
import { WORKSPACE_DELETE_CONFIRMATION_ERROR } from "./delete-project-confirmation";
const mocks = vi.hoisted(() => ({
checkAuthorizationUpdated: vi.fn(),
deleteProject: vi.fn(),
getProject: vi.fn(),
getUserProjects: vi.fn(),
}));
vi.mock("@/lib/project/service", () => ({
getProject: mocks.getProject,
getUserProjects: mocks.getUserProjects,
}));
vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({
checkAuthorizationUpdated: mocks.checkAuthorizationUpdated,
}));
vi.mock("@/modules/projects/settings/lib/project", () => ({
deleteProject: mocks.deleteProject,
}));
const baseProject = {
id: "cmproject000000000000000000",
name: "Acme Workspace",
organizationId: "cmorg00000000000000000000",
};
const userId = "cmuser00000000000000000000";
const callDeleteProjectWithConfirmation = (input = {}) =>
deleteProjectWithConfirmation({
input: {
projectId: baseProject.id,
confirmationName: baseProject.name,
...input,
},
userId,
auditLoggingCtx: {},
});
describe("deleteProjectWithConfirmation", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.checkAuthorizationUpdated.mockResolvedValue(undefined);
mocks.getProject.mockResolvedValue(baseProject);
mocks.getUserProjects.mockResolvedValue([baseProject, { ...baseProject, id: "cmproject2" }]);
mocks.deleteProject.mockResolvedValue(baseProject);
});
test("deletes a workspace when the confirmation name matches", async () => {
const auditLoggingCtx = {};
const result = await deleteProjectWithConfirmation({
input: {
projectId: baseProject.id,
confirmationName: "acme workspace",
},
userId,
auditLoggingCtx,
});
expect(mocks.checkAuthorizationUpdated).toHaveBeenCalledWith({
userId,
organizationId: baseProject.organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
],
});
expect(mocks.getUserProjects).toHaveBeenCalledWith(userId, baseProject.organizationId);
expect(mocks.deleteProject).toHaveBeenCalledWith(baseProject.id);
expect(auditLoggingCtx).toMatchObject({
organizationId: baseProject.organizationId,
projectId: baseProject.id,
oldObject: baseProject,
});
expect(result).toEqual(baseProject);
});
test("rejects invalid input before any project lookup", async () => {
await expect(
deleteProjectWithConfirmation({
input: {},
userId,
auditLoggingCtx: {},
})
).rejects.toThrow(InvalidInputError);
await expect(
deleteProjectWithConfirmation({
input: {},
userId,
auditLoggingCtx: {},
})
).rejects.toThrow(DELETE_PROJECT_CONFIRMATION_REQUIRED_ERROR);
expect(mocks.getProject).not.toHaveBeenCalled();
expect(mocks.deleteProject).not.toHaveBeenCalled();
});
test("does not delete when the confirmation name does not match", async () => {
await expect(callDeleteProjectWithConfirmation({ confirmationName: "Other Workspace" })).rejects.toThrow(
InvalidInputError
);
await expect(callDeleteProjectWithConfirmation({ confirmationName: "Other Workspace" })).rejects.toThrow(
WORKSPACE_DELETE_CONFIRMATION_ERROR
);
expect(mocks.deleteProject).not.toHaveBeenCalled();
});
test("does not delete when the workspace cannot be found", async () => {
mocks.getProject.mockResolvedValueOnce(null);
await expect(callDeleteProjectWithConfirmation()).rejects.toThrow(ResourceNotFoundError);
expect(mocks.checkAuthorizationUpdated).not.toHaveBeenCalled();
expect(mocks.deleteProject).not.toHaveBeenCalled();
});
test("does not delete when authorization fails", async () => {
mocks.checkAuthorizationUpdated.mockRejectedValueOnce(new AuthorizationError("Not authorized"));
await expect(callDeleteProjectWithConfirmation()).rejects.toThrow(AuthorizationError);
expect(mocks.getUserProjects).not.toHaveBeenCalled();
expect(mocks.deleteProject).not.toHaveBeenCalled();
});
test("does not delete the last available workspace", async () => {
mocks.getUserProjects.mockResolvedValueOnce([baseProject]);
await expect(callDeleteProjectWithConfirmation()).rejects.toThrow(OperationNotAllowedError);
expect(mocks.deleteProject).not.toHaveBeenCalled();
});
test("rethrows downstream delete failures", async () => {
const error = new Error("delete failed");
mocks.deleteProject.mockRejectedValueOnce(error);
await expect(callDeleteProjectWithConfirmation()).rejects.toThrow(error);
});
});
describe("getProjectIdForLogging", () => {
test("returns the project id when present", () => {
expect(getProjectIdForLogging({ projectId: baseProject.id })).toBe(baseProject.id);
});
test("returns unknown when the project id is missing or invalid", () => {
expect(getProjectIdForLogging({})).toBe("unknown");
expect(getProjectIdForLogging({ projectId: 123 })).toBe("unknown");
expect(getProjectIdForLogging(null)).toBe("unknown");
});
});
@@ -0,0 +1,94 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { InvalidInputError, OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getProject, getUserProjects } from "@/lib/project/service";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { deleteProject } from "@/modules/projects/settings/lib/project";
import {
WORKSPACE_DELETE_CONFIRMATION_ERROR,
hasMatchingWorkspaceDeleteConfirmation,
} from "./delete-project-confirmation";
const ZProjectDeleteAction = z.object({
projectId: ZId,
confirmationName: z.string().trim().min(1),
});
export const DELETE_PROJECT_CONFIRMATION_REQUIRED_ERROR =
"Workspace name confirmation is required to delete this workspace.";
export const parseProjectDeleteActionInput = (input: unknown) => {
const parsedInput = ZProjectDeleteAction.safeParse(input);
if (!parsedInput.success) {
throw new InvalidInputError(DELETE_PROJECT_CONFIRMATION_REQUIRED_ERROR);
}
return parsedInput.data;
};
export const getProjectIdForLogging = (input: unknown) => {
if (typeof input !== "object" || input === null || !("projectId" in input)) {
return "unknown";
}
const projectId = input.projectId;
return typeof projectId === "string" ? projectId : "unknown";
};
const assertMatchingWorkspaceDeleteConfirmation = (confirmationName: string, workspaceName: string) => {
if (!hasMatchingWorkspaceDeleteConfirmation(confirmationName, workspaceName)) {
throw new InvalidInputError(WORKSPACE_DELETE_CONFIRMATION_ERROR);
}
};
interface DeleteProjectWithConfirmationParams {
input: unknown;
userId: string;
auditLoggingCtx: {
organizationId?: string;
projectId?: string;
oldObject?: unknown;
};
}
export const deleteProjectWithConfirmation = async ({
input,
userId,
auditLoggingCtx,
}: DeleteProjectWithConfirmationParams) => {
const { confirmationName, projectId } = parseProjectDeleteActionInput(input);
const project = await getProject(projectId);
if (!project) {
throw new ResourceNotFoundError("project", projectId);
}
const organizationId = project.organizationId;
await checkAuthorizationUpdated({
userId,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
],
});
const availableProjects = await getUserProjects(userId, organizationId);
if (availableProjects.length <= 1) {
throw new OperationNotAllowedError("You can't delete the last project in the environment.");
}
assertMatchingWorkspaceDeleteConfirmation(confirmationName, project.name);
auditLoggingCtx.organizationId = organizationId;
auditLoggingCtx.projectId = projectId;
auditLoggingCtx.oldObject = project;
return await deleteProject(projectId);
};
+141
View File
@@ -0,0 +1,141 @@
import { expect } from "@playwright/test";
import { prisma } from "@formbricks/database";
import { test } from "./lib/fixtures";
const createEnvironmentData = (type: "development" | "production") => ({
type,
attributeKeys: {
create: [
{
name: "Email",
key: "email",
isUnique: true,
type: "default" as const,
},
{
name: "First Name",
key: "firstName",
isUnique: false,
type: "default" as const,
},
{
name: "Last Name",
key: "lastName",
isUnique: false,
type: "default" as const,
},
{
name: "userId",
key: "userId",
isUnique: true,
type: "default" as const,
},
],
},
});
const getProjectForEmail = async (email: string) => {
const user = await prisma.user.findUnique({
where: {
email,
},
select: {
memberships: {
select: {
organizationId: true,
organization: {
select: {
projects: {
select: {
id: true,
name: true,
environments: {
select: {
id: true,
type: true,
},
},
},
take: 1,
},
},
},
},
take: 1,
},
},
});
const membership = user?.memberships[0];
const project = membership?.organization.projects[0];
const productionEnvironment = project?.environments.find(
(environment) => environment.type === "production"
);
if (!membership || !project || !productionEnvironment) {
throw new Error(`Project not found for email: ${email}`);
}
return {
organizationId: membership.organizationId,
projectId: project.id,
projectName: project.name,
productionEnvironmentId: productionEnvironment.id,
};
};
test("requires project name confirmation before deleting a project", async ({ page, users }) => {
const timestamp = Date.now();
const email = `project-delete-${timestamp}@example.com`;
const projectName = `Delete Project ${timestamp}`;
const remainingProjectName = `Remaining Project ${timestamp}`;
const user = await users.create({
email,
name: `project-delete-${timestamp}`,
projectName,
});
const project = await getProjectForEmail(email);
const remainingProject = await prisma.project.create({
data: {
name: remainingProjectName,
organizationId: project.organizationId,
environments: {
create: [createEnvironmentData("development"), createEnvironmentData("production")],
},
},
select: {
id: true,
},
});
const remainingProductionEnvironment = await prisma.environment.findFirst({
where: {
projectId: remainingProject.id,
type: "production",
},
select: {
id: true,
},
});
const remainingProductionEnvironmentId = remainingProductionEnvironment?.id;
if (!remainingProductionEnvironmentId) {
throw new Error("Remaining project production environment not found");
}
await user.login();
await page.goto(`/environments/${project.productionEnvironmentId}/workspace/general`, {
waitUntil: "domcontentloaded",
});
await page.getByRole("button", { name: "Delete", exact: true }).click();
const dialog = page.getByRole("dialog");
await expect(dialog.getByRole("button", { name: "Delete", exact: true })).toBeDisabled();
await page.locator("#deleteProjectConfirmation").fill(project.projectName.toUpperCase());
await expect(dialog.getByRole("button", { name: "Delete", exact: true })).toBeEnabled();
await dialog.getByRole("button", { name: "Delete", exact: true }).click();
await expect(page.getByText("Workspace deleted successfully", { exact: true })).toBeVisible();
await page.waitForURL(new RegExp(`/environments/${remainingProductionEnvironmentId}/surveys`));
await expect.poll(async () => prisma.project.findUnique({ where: { id: project.projectId } })).toBeNull();
});