diff --git a/apps/web/i18n.lock b/apps/web/i18n.lock index 10e633eddf06..4c46cc0576e1 100644 --- a/apps/web/i18n.lock +++ b/apps/web/i18n.lock @@ -613,6 +613,14 @@ checksums: emails/response_finished_email_subject: 7e8b92b483242ddb31ba83e8fcf890f9 emails/schedule_your_meeting: 01683323bd7373560cd2cb2737dbaf06 emails/select_a_date: 521e4a705800da06d091fde3e801ce02 + emails/sso_recovery_factors_removed_email_did_not_expect: 30209a930b9cd3dcc05b2fc8eb5e5f0f + emails/sso_recovery_factors_removed_email_heading: cd9ed20b14050b7ab38fb9e6537ad161 + emails/sso_recovery_factors_removed_email_password: a5acd53c33bfb0d4360931b8cfd1eb09 + emails/sso_recovery_factors_removed_email_review_security: 651d94f70a60445c61f95ea1ab337a44 + emails/sso_recovery_factors_removed_email_sign_in_hint: f20332a23dd3e882c507792a3f9927dc + emails/sso_recovery_factors_removed_email_subject: 1de9cb05afff7ce807dfab3511bda848 + emails/sso_recovery_factors_removed_email_text: fcf21b6d3fd4408b1b433d1be3e8f509 + emails/sso_recovery_factors_removed_email_two_factor: 83d572df760fdce5cdb8fa8df9ea07ad emails/survey_response_finished_email_congrats: 4cc39698d6a16f68cf7d9db902c5978e emails/survey_response_finished_email_dont_want_notifications: 749171800180682839896dc04a53ca10 emails/survey_response_finished_email_hey: 20c5157a424f7d49ceeb27e6fb13d194 diff --git a/apps/web/lib/surveyLogic/utils.test.ts b/apps/web/lib/surveyLogic/utils.test.ts index 710bf693791f..58db8ce8dae4 100644 --- a/apps/web/lib/surveyLogic/utils.test.ts +++ b/apps/web/lib/surveyLogic/utils.test.ts @@ -244,6 +244,55 @@ describe("surveyLogic", () => { expect(evaluateLogic(mockSurvey, data, vars, group, "en")).toBe(true); }); + test("evaluateLogic treats doesNotEqual as the inverse of equals for single-selection answers", () => { + const multiSurvey: TJsWorkspaceStateSurvey = { + ...mockSurvey, + blocks: [ + ...mockSurvey.blocks, + { + id: "multiBlock", + name: "Multi Choice Block", + elements: [ + { + id: "multiQ", + type: TSurveyElementTypeEnum.MultipleChoiceMulti, + headline: { default: "Multiple Choice" }, + required: true, + choices: [ + { id: "opt1", label: { default: "Option 1" } }, + { id: "opt2", label: { default: "Option 2" } }, + ], + }, + ], + }, + ], + }; + // answer labels resolve to choice ids, so a single selection of "Option 1" becomes ["opt1"] + const data: TResponseData = { multiQ: ["Option 1"] }; + const vars: TResponseVariables = {}; + + const condition = (operator: "equals" | "doesNotEqual", value: string): TConditionGroup => ({ + id: "g", + connector: "and", + conditions: [ + { + id: "c1", + operator, + leftOperand: { type: "element", value: "multiQ" }, + rightOperand: { type: "static", value }, + }, + ], + }); + + // the selection matches the condition value: equals is true, so doesNotEqual must be false + expect(evaluateLogic(multiSurvey, data, vars, condition("equals", "opt1"), "en")).toBe(true); + expect(evaluateLogic(multiSurvey, data, vars, condition("doesNotEqual", "opt1"), "en")).toBe(false); + + // the selection does not match: equals is false, doesNotEqual is true + expect(evaluateLogic(multiSurvey, data, vars, condition("equals", "opt2"), "en")).toBe(false); + expect(evaluateLogic(multiSurvey, data, vars, condition("doesNotEqual", "opt2"), "en")).toBe(true); + }); + test("performActions calculates, requires, and jumps correctly", () => { const data: TResponseData = { q: "5" }; const initialVars: TResponseVariables = {}; diff --git a/apps/web/lib/surveyLogic/utils.ts b/apps/web/lib/surveyLogic/utils.ts index cc2c02860f30..398094f87977 100644 --- a/apps/web/lib/surveyLogic/utils.ts +++ b/apps/web/lib/surveyLogic/utils.ts @@ -376,13 +376,13 @@ const evaluateSingleCondition = ( } } - return ( - (Array.isArray(leftValue) && - leftValue.length === 1 && - typeof rightValue === "string" && - !leftValue.includes(rightValue)) || - leftValue !== rightValue - ); + // decide inside the guard: OR-ing past it would fall through to `leftValue !== rightValue`, + // which is always true for an array vs. a string (a matching single selection included) + if (Array.isArray(leftValue) && leftValue.length === 1 && typeof rightValue === "string") { + return !leftValue.includes(rightValue); + } + + return leftValue !== rightValue; case "contains": return String(leftValue).includes(String(rightValue)); case "doesNotContain": diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json index 5832bcf98f58..817a7d179100 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -644,6 +644,14 @@ "response_finished_email_subject": "Eine Antwort für {surveyName} wurde ausgefüllt ✅", "schedule_your_meeting": "Plane dein Meeting", "select_a_date": "Wähle ein Datum", + "sso_recovery_factors_removed_email_did_not_expect": "Falls du dich gerade nicht per Single Sign-On angemeldet hast, kontaktiere sofort deinen Administrator.", + "sso_recovery_factors_removed_email_heading": "Anmeldemethoden von deinem Konto entfernt", + "sso_recovery_factors_removed_email_password": "Dein Passwort wurde entfernt.", + "sso_recovery_factors_removed_email_review_security": "Sicherheitseinstellungen überprüfen", + "sso_recovery_factors_removed_email_sign_in_hint": "Du kannst dich weiterhin per Single Sign-On anmelden. Um wieder ein Passwort oder Zwei-Faktor-Authentifizierung zu verwenden, richte sie in deinen Sicherheitseinstellungen ein.", + "sso_recovery_factors_removed_email_subject": "Anmeldemethoden wurden von deinem Formbricks-Konto entfernt", + "sso_recovery_factors_removed_email_text": "Du hast dich gerade zum ersten Mal per Single Sign-On angemeldet. Da deine E-Mail-Adresse nie verifiziert wurde, haben wir die Anmeldemethoden entfernt, die zuvor für das Konto eingerichtet waren:", + "sso_recovery_factors_removed_email_two_factor": "Zwei-Faktor-Authentifizierung wurde entfernt.", "survey_response_finished_email_congrats": "Glückwunsch, du hast eine neue Antwort auf deine Umfrage erhalten! Jemand hat gerade deine Umfrage ausgefüllt: {surveyName}", "survey_response_finished_email_dont_want_notifications": "Möchtest du diese Benachrichtigungen nicht mehr erhalten?", "survey_response_finished_email_hey": "Hey 👋", diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index f9e2636895cf..37f20ccaf822 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -644,6 +644,14 @@ "response_finished_email_subject": "A response for {surveyName} was completed ✅", "schedule_your_meeting": "Schedule your meeting", "select_a_date": "Select a date", + "sso_recovery_factors_removed_email_did_not_expect": "If you did not just sign in with single sign-on, contact your administrator immediately.", + "sso_recovery_factors_removed_email_heading": "Sign-in factors removed from your account", + "sso_recovery_factors_removed_email_password": "Your password was removed.", + "sso_recovery_factors_removed_email_review_security": "Review security settings", + "sso_recovery_factors_removed_email_sign_in_hint": "You can keep signing in with single sign-on. To use a password or two-factor authentication again, set them up in your security settings.", + "sso_recovery_factors_removed_email_subject": "Sign-in factors were removed from your Formbricks account", + "sso_recovery_factors_removed_email_text": "You just signed in with single sign-on for the first time. Because your email address had never been verified, we removed the sign-in factors that were set on the account before:", + "sso_recovery_factors_removed_email_two_factor": "Two-factor authentication was removed.", "survey_response_finished_email_congrats": "Congrats, you received a new response to your survey! Someone just completed your survey: {surveyName}", "survey_response_finished_email_dont_want_notifications": "Do not want to get these notifications?", "survey_response_finished_email_hey": "Hey 👋", diff --git a/apps/web/locales/es-ES.json b/apps/web/locales/es-ES.json index 483b691a35df..fb8e69737c9a 100644 --- a/apps/web/locales/es-ES.json +++ b/apps/web/locales/es-ES.json @@ -644,6 +644,14 @@ "response_finished_email_subject": "Se completó una respuesta para {surveyName} ✅", "schedule_your_meeting": "Programa tu reunión", "select_a_date": "Selecciona una fecha", + "sso_recovery_factors_removed_email_did_not_expect": "Si no acabas de iniciar sesión con inicio de sesión único, contacta con tu administrador inmediatamente.", + "sso_recovery_factors_removed_email_heading": "Factores de inicio de sesión eliminados de tu cuenta", + "sso_recovery_factors_removed_email_password": "Tu contraseña fue eliminada.", + "sso_recovery_factors_removed_email_review_security": "Revisar configuración de seguridad", + "sso_recovery_factors_removed_email_sign_in_hint": "Puedes seguir iniciando sesión con inicio de sesión único. Para usar de nuevo una contraseña o autenticación de dos factores, configúralas en tu configuración de seguridad.", + "sso_recovery_factors_removed_email_subject": "Se eliminaron factores de inicio de sesión de tu cuenta de Formbricks", + "sso_recovery_factors_removed_email_text": "Acabas de iniciar sesión con inicio de sesión único por primera vez. Como tu dirección de correo electrónico nunca había sido verificada, eliminamos los factores de inicio de sesión que estaban configurados en la cuenta anteriormente:", + "sso_recovery_factors_removed_email_two_factor": "La autenticación de dos factores fue eliminada.", "survey_response_finished_email_congrats": "Enhorabuena, has recibido una nueva respuesta a tu encuesta. Alguien acaba de completar tu encuesta: {surveyName}", "survey_response_finished_email_dont_want_notifications": "¿No quieres recibir estas notificaciones?", "survey_response_finished_email_hey": "Hola 👋", diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json index 62ae0bc9a0d8..a8fb13c6ca08 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -644,6 +644,14 @@ "response_finished_email_subject": "Une réponse pour {surveyName} a été complétée ✅", "schedule_your_meeting": "Planifier votre rendez-vous", "select_a_date": "Sélectionner une date", + "sso_recovery_factors_removed_email_did_not_expect": "Si tu ne viens pas de te connecter avec l'authentification unique, contacte ton administrateur immédiatement.", + "sso_recovery_factors_removed_email_heading": "Méthodes de connexion supprimées de ton compte", + "sso_recovery_factors_removed_email_password": "Ton mot de passe a été supprimé.", + "sso_recovery_factors_removed_email_review_security": "Consulter les paramètres de sécurité", + "sso_recovery_factors_removed_email_sign_in_hint": "Tu peux continuer à te connecter avec l'authentification unique. Pour utiliser à nouveau un mot de passe ou l'authentification à deux facteurs, configure-les dans tes paramètres de sécurité.", + "sso_recovery_factors_removed_email_subject": "Des méthodes de connexion ont été supprimées de ton compte Formbricks", + "sso_recovery_factors_removed_email_text": "Tu viens de te connecter avec l'authentification unique pour la première fois. Comme ton adresse e-mail n'avait jamais été vérifiée, nous avons supprimé les méthodes de connexion qui étaient configurées auparavant sur le compte :", + "sso_recovery_factors_removed_email_two_factor": "L'authentification à deux facteurs a été supprimée.", "survey_response_finished_email_congrats": "Félicitations, vous avez reçu une nouvelle réponse à votre enquête ! Quelqu'un vient de compléter votre enquête : {surveyName}", "survey_response_finished_email_dont_want_notifications": "Vous ne voulez pas recevoir ces notifications ?", "survey_response_finished_email_hey": "Salut 👋", diff --git a/apps/web/locales/hu-HU.json b/apps/web/locales/hu-HU.json index dc63afdb3fc9..ee07cee85b2a 100644 --- a/apps/web/locales/hu-HU.json +++ b/apps/web/locales/hu-HU.json @@ -644,6 +644,14 @@ "response_finished_email_subject": "A(z) {surveyName} kérdőívre adott válasz befejeződött ✅", "schedule_your_meeting": "Megbeszélés ütemezése", "select_a_date": "Dátum kiválasztása", + "sso_recovery_factors_removed_email_did_not_expect": "Amennyiben nem Ön jelentkezett be egyszeri bejelentkezéssel, kérjük, haladéktalanul vegye fel a kapcsolatot az adminisztrátorával.", + "sso_recovery_factors_removed_email_heading": "A bejelentkezési tényezők eltávolításra kerültek a fiókjából", + "sso_recovery_factors_removed_email_password": "A jelszava eltávolításra került.", + "sso_recovery_factors_removed_email_review_security": "Biztonsági beállítások áttekintése", + "sso_recovery_factors_removed_email_sign_in_hint": "A továbbiakban egyszeri bejelentkezéssel jelentkezhet be. Amennyiben ismételten jelszót vagy kétfaktoros hitelesítést kíván használni, kérjük, állítsa be azokat a biztonsági beállításokban.", + "sso_recovery_factors_removed_email_subject": "Bejelentkezési tényezők eltávolításra kerültek az Ön Formbricks fiókjából", + "sso_recovery_factors_removed_email_text": "Ön most jelentkezett be először egyszeri bejelentkezéssel. Mivel az e-mail címe korábban nem került megerősítésre, eltávolítottuk a fiókon korábban beállított bejelentkezési tényezőket:", + "sso_recovery_factors_removed_email_two_factor": "A kétfaktoros hitelesítés eltávolításra került.", "survey_response_finished_email_congrats": "Gratulálunk, új válasz érkezett a kérdőívére! Valaki épp most töltötte ki ezt a kérdőívet: {surveyName}", "survey_response_finished_email_dont_want_notifications": "Nem szeretné megkapni ezeket az értesítéseket?", "survey_response_finished_email_hey": "Helló 👋", diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json index 9301e5251fc3..91db6b04c793 100644 --- a/apps/web/locales/ja-JP.json +++ b/apps/web/locales/ja-JP.json @@ -644,6 +644,14 @@ "response_finished_email_subject": "{surveyName} の回答が完了しました ✅", "schedule_your_meeting": "ミーティングを予約", "select_a_date": "日付を選択", + "sso_recovery_factors_removed_email_did_not_expect": "シングルサインオンでサインインした覚えがない場合は、すぐに管理者に連絡してください。", + "sso_recovery_factors_removed_email_heading": "アカウントからサインイン要素が削除されました", + "sso_recovery_factors_removed_email_password": "パスワードが削除されました。", + "sso_recovery_factors_removed_email_review_security": "セキュリティ設定を確認する", + "sso_recovery_factors_removed_email_sign_in_hint": "今後もシングルサインオンでサインインできます。パスワードや二要素認証を再度使用したい場合は、セキュリティ設定で設定してください。", + "sso_recovery_factors_removed_email_subject": "Formbricksアカウントからサインイン要素が削除されました", + "sso_recovery_factors_removed_email_text": "初めてシングルサインオンでサインインしました。メールアドレスが未確認だったため、以前アカウントに設定されていた以下のサインイン要素を削除しました:", + "sso_recovery_factors_removed_email_two_factor": "二要素認証が削除されました。", "survey_response_finished_email_congrats": "おめでとうございます、新しい回答が届きました!{surveyName} フォームへの回答が完了しました。", "survey_response_finished_email_dont_want_notifications": "これらの通知を受け取りたくないですか?", "survey_response_finished_email_hey": "こんにちは 👋", diff --git a/apps/web/locales/nl-NL.json b/apps/web/locales/nl-NL.json index 99d78e3079ee..fc98976066a0 100644 --- a/apps/web/locales/nl-NL.json +++ b/apps/web/locales/nl-NL.json @@ -644,6 +644,14 @@ "response_finished_email_subject": "Er is een reactie voor {surveyName} voltooid ✅", "schedule_your_meeting": "Plan uw vergadering", "select_a_date": "Selecteer een datum", + "sso_recovery_factors_removed_email_did_not_expect": "Als je niet zojuist bent ingelogd met single sign-on, neem dan onmiddellijk contact op met je beheerder.", + "sso_recovery_factors_removed_email_heading": "Inlogfactoren verwijderd van je account", + "sso_recovery_factors_removed_email_password": "Je wachtwoord is verwijderd.", + "sso_recovery_factors_removed_email_review_security": "Bekijk beveiligingsinstellingen", + "sso_recovery_factors_removed_email_sign_in_hint": "Je kunt blijven inloggen met single sign-on. Om opnieuw een wachtwoord of tweefactorauthenticatie te gebruiken, stel je deze in via je beveiligingsinstellingen.", + "sso_recovery_factors_removed_email_subject": "Inlogfactoren zijn verwijderd van je Formbricks-account", + "sso_recovery_factors_removed_email_text": "Je bent zojuist voor het eerst ingelogd met single sign-on. Omdat je e-mailadres nog nooit was geverifieerd, hebben we de inlogfactoren verwijderd die eerder op het account waren ingesteld:", + "sso_recovery_factors_removed_email_two_factor": "Tweefactorauthenticatie is verwijderd.", "survey_response_finished_email_congrats": "Gefeliciteerd, u heeft een nieuwe reactie op uw enquête ontvangen! Iemand heeft zojuist uw enquête ingevuld: {surveyName}", "survey_response_finished_email_dont_want_notifications": "Wil je deze meldingen niet ontvangen?", "survey_response_finished_email_hey": "Hé 👋", diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json index ec635f3136e5..19823d85d2bd 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -644,6 +644,14 @@ "response_finished_email_subject": "Uma resposta para {surveyName} foi concluída ✅", "schedule_your_meeting": "Agendar sua reunião", "select_a_date": "Selecione uma data", + "sso_recovery_factors_removed_email_did_not_expect": "Se você não acabou de fazer login com logon único, entre em contato com seu administrador imediatamente.", + "sso_recovery_factors_removed_email_heading": "Fatores de autenticação removidos da sua conta", + "sso_recovery_factors_removed_email_password": "Sua senha foi removida.", + "sso_recovery_factors_removed_email_review_security": "Revisar configurações de segurança", + "sso_recovery_factors_removed_email_sign_in_hint": "Você pode continuar fazendo login com logon único. Para usar uma senha ou autenticação de dois fatores novamente, configure-os nas suas configurações de segurança.", + "sso_recovery_factors_removed_email_subject": "Fatores de autenticação foram removidos da sua conta Formbricks", + "sso_recovery_factors_removed_email_text": "Você acabou de fazer login com logon único pela primeira vez. Como seu endereço de e-mail nunca havia sido verificado, removemos os fatores de autenticação que estavam configurados na conta anteriormente:", + "sso_recovery_factors_removed_email_two_factor": "A autenticação de dois fatores foi removida.", "survey_response_finished_email_congrats": "Parabéns, você recebeu uma nova resposta na sua pesquisa! Alguém acabou de completar sua pesquisa: {surveyName}", "survey_response_finished_email_dont_want_notifications": "Não quer receber essas notificações?", "survey_response_finished_email_hey": "E aí 👋", diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json index b6e1b6b60fa5..b429c02c207c 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -644,6 +644,14 @@ "response_finished_email_subject": "Uma resposta para {surveyName} foi concluída ✅", "schedule_your_meeting": "Agende a sua reunião", "select_a_date": "Selecionar uma data", + "sso_recovery_factors_removed_email_did_not_expect": "Se não acabaste de iniciar sessão com single sign-on, contacta o teu administrador imediatamente.", + "sso_recovery_factors_removed_email_heading": "Fatores de início de sessão removidos da tua conta", + "sso_recovery_factors_removed_email_password": "A tua palavra-passe foi removida.", + "sso_recovery_factors_removed_email_review_security": "Rever definições de segurança", + "sso_recovery_factors_removed_email_sign_in_hint": "Podes continuar a iniciar sessão com single sign-on. Para voltares a usar uma palavra-passe ou autenticação de dois fatores, configura-as nas tuas definições de segurança.", + "sso_recovery_factors_removed_email_subject": "Fatores de início de sessão foram removidos da tua conta Formbricks", + "sso_recovery_factors_removed_email_text": "Acabaste de iniciar sessão com single sign-on pela primeira vez. Como o teu endereço de email nunca tinha sido verificado, removemos os fatores de início de sessão que estavam configurados na conta anteriormente:", + "sso_recovery_factors_removed_email_two_factor": "A autenticação de dois fatores foi removida.", "survey_response_finished_email_congrats": "Parabéns, recebeu uma nova resposta ao seu inquérito! Alguém acabou de completar o seu inquérito: {surveyName}", "survey_response_finished_email_dont_want_notifications": "Não quer receber estas notificações?", "survey_response_finished_email_hey": "Olá 👋", diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json index dfee8931c003..79b7b2b48521 100644 --- a/apps/web/locales/ro-RO.json +++ b/apps/web/locales/ro-RO.json @@ -644,6 +644,14 @@ "response_finished_email_subject": "Un răspuns pentru {surveyName} a fost finalizat ✅", "schedule_your_meeting": "Programați întâlnirea", "select_a_date": "Selectați o dată", + "sso_recovery_factors_removed_email_did_not_expect": "Dacă nu tocmai te-ai autentificat cu single sign-on, contactează imediat administratorul tău.", + "sso_recovery_factors_removed_email_heading": "Metodele de autentificare au fost eliminate din contul tău", + "sso_recovery_factors_removed_email_password": "Parola ta a fost eliminată.", + "sso_recovery_factors_removed_email_review_security": "Verifică setările de securitate", + "sso_recovery_factors_removed_email_sign_in_hint": "Poți continua să te autentifici cu single sign-on. Pentru a folosi din nou o parolă sau autentificarea în doi pași, configurează-le în setările tale de securitate.", + "sso_recovery_factors_removed_email_subject": "Metodele de autentificare au fost eliminate din contul tău Formbricks", + "sso_recovery_factors_removed_email_text": "Tocmai te-ai autentificat cu single sign-on pentru prima dată. Deoarece adresa ta de email nu fusese niciodată verificată, am eliminat metodele de autentificare care fuseseră configurate anterior pe cont:", + "sso_recovery_factors_removed_email_two_factor": "Autentificarea în doi pași a fost eliminată.", "survey_response_finished_email_congrats": "Felicitări, aţi primit un răspuns nou la sondaj! Cineva tocmai a completat sondajul dumneavoastră: {surveyName}", "survey_response_finished_email_dont_want_notifications": "Nu doriți să primiți aceste notificări?", "survey_response_finished_email_hey": "Hei 👋", diff --git a/apps/web/locales/ru-RU.json b/apps/web/locales/ru-RU.json index cc4c93d81f24..8e27e6479bbf 100644 --- a/apps/web/locales/ru-RU.json +++ b/apps/web/locales/ru-RU.json @@ -644,6 +644,14 @@ "response_finished_email_subject": "Ответ на {surveyName} был получен ✅", "schedule_your_meeting": "Запланируйте встречу", "select_a_date": "Выберите дату", + "sso_recovery_factors_removed_email_did_not_expect": "Если ты не выполнял вход через единую систему аутентификации, немедленно свяжись со своим администратором.", + "sso_recovery_factors_removed_email_heading": "Способы входа удалены из твоей учётной записи", + "sso_recovery_factors_removed_email_password": "Твой пароль был удалён.", + "sso_recovery_factors_removed_email_review_security": "Проверить настройки безопасности", + "sso_recovery_factors_removed_email_sign_in_hint": "Ты можешь продолжать входить через единую систему аутентификации. Чтобы снова использовать пароль или двухфакторную аутентификацию, настрой их в параметрах безопасности.", + "sso_recovery_factors_removed_email_subject": "Способы входа были удалены из твоей учётной записи Formbricks", + "sso_recovery_factors_removed_email_text": "Ты только что впервые выполнил вход через единую систему аутентификации. Поскольку твой адрес электронной почты никогда не был подтверждён, мы удалили способы входа, которые были настроены в учётной записи ранее:", + "sso_recovery_factors_removed_email_two_factor": "Двухфакторная аутентификация была удалена.", "survey_response_finished_email_congrats": "Поздравляем, вы получили новый ответ на свой опрос! Кто-то только что завершил ваш опрос: {surveyName}", "survey_response_finished_email_dont_want_notifications": "Не хотите получать эти уведомления?", "survey_response_finished_email_hey": "Привет 👋", diff --git a/apps/web/locales/sv-SE.json b/apps/web/locales/sv-SE.json index 5d464927ea25..682d8667fa8d 100644 --- a/apps/web/locales/sv-SE.json +++ b/apps/web/locales/sv-SE.json @@ -644,6 +644,14 @@ "response_finished_email_subject": "Ett svar för {surveyName} har slutförts ✅", "schedule_your_meeting": "Boka ditt möte", "select_a_date": "Välj ett datum", + "sso_recovery_factors_removed_email_did_not_expect": "Om du inte just loggade in med enkel inloggning, kontakta din administratör omedelbart.", + "sso_recovery_factors_removed_email_heading": "Inloggningsfaktorer borttagna från ditt konto", + "sso_recovery_factors_removed_email_password": "Ditt lösenord togs bort.", + "sso_recovery_factors_removed_email_review_security": "Granska säkerhetsinställningar", + "sso_recovery_factors_removed_email_sign_in_hint": "Du kan fortsätta logga in med enkel inloggning. För att använda lösenord eller tvåfaktorsautentisering igen, ställ in dem i dina säkerhetsinställningar.", + "sso_recovery_factors_removed_email_subject": "Inloggningsfaktorer togs bort från ditt Formbricks-konto", + "sso_recovery_factors_removed_email_text": "Du loggade just in med enkel inloggning för första gången. Eftersom din e-postadress aldrig hade verifierats tog vi bort de inloggningsfaktorer som var inställda på kontot tidigare:", + "sso_recovery_factors_removed_email_two_factor": "Tvåfaktorsautentisering togs bort.", "survey_response_finished_email_congrats": "Grattis, du har fått ett nytt svar på din enkät! Någon har precis slutfört din enkät: {surveyName}", "survey_response_finished_email_dont_want_notifications": "Vill du inte få dessa aviseringar?", "survey_response_finished_email_hey": "Hej 👋", diff --git a/apps/web/locales/tr-TR.json b/apps/web/locales/tr-TR.json index 084f86e3dbba..125a6fa6e338 100644 --- a/apps/web/locales/tr-TR.json +++ b/apps/web/locales/tr-TR.json @@ -644,6 +644,14 @@ "response_finished_email_subject": "{surveyName} için bir yanıt tamamlandı ✅", "schedule_your_meeting": "Toplantınızı planlayın", "select_a_date": "Bir tarih seçin", + "sso_recovery_factors_removed_email_did_not_expect": "Tek oturum açma ile giriş yapmadıysan, hemen yöneticinle iletişime geç.", + "sso_recovery_factors_removed_email_heading": "Hesabından oturum açma faktörleri kaldırıldı", + "sso_recovery_factors_removed_email_password": "Şifren kaldırıldı.", + "sso_recovery_factors_removed_email_review_security": "Güvenlik ayarlarını incele", + "sso_recovery_factors_removed_email_sign_in_hint": "Tek oturum açma ile giriş yapmaya devam edebilirsin. Şifre veya iki faktörlü kimlik doğrulamayı tekrar kullanmak için güvenlik ayarlarından kurulum yapabilirsin.", + "sso_recovery_factors_removed_email_subject": "Formbricks hesabından oturum açma faktörleri kaldırıldı", + "sso_recovery_factors_removed_email_text": "Az önce ilk kez tek oturum açma ile giriş yaptın. E-posta adresen daha önce hiç doğrulanmadığı için hesabında daha önce ayarlanmış olan şu oturum açma faktörlerini kaldırdık:", + "sso_recovery_factors_removed_email_two_factor": "İki faktörlü kimlik doğrulama kaldırıldı.", "survey_response_finished_email_congrats": "Tebrikler, survey'inize yeni bir yanıt aldınız! Birisi survey'inizi tamamladı: {surveyName}", "survey_response_finished_email_dont_want_notifications": "Bu bildirimleri almak istemiyor musunuz?", "survey_response_finished_email_hey": "Merhaba 👋", diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json index d2578fd75eeb..d48746084559 100644 --- a/apps/web/locales/zh-Hans-CN.json +++ b/apps/web/locales/zh-Hans-CN.json @@ -644,6 +644,14 @@ "response_finished_email_subject": "对 {surveyName} 的回答已完成 ✅", "schedule_your_meeting": "安排你的会议", "select_a_date": "选择 日期", + "sso_recovery_factors_removed_email_did_not_expect": "如果你刚才没有使用单点登录进行登录,请立即联系你的管理员。", + "sso_recovery_factors_removed_email_heading": "你的账户登录方式已被移除", + "sso_recovery_factors_removed_email_password": "你的密码已被移除。", + "sso_recovery_factors_removed_email_review_security": "查看安全设置", + "sso_recovery_factors_removed_email_sign_in_hint": "你可以继续使用单点登录进行登录。如果想再次使用密码或双因素认证,请在安全设置中进行配置。", + "sso_recovery_factors_removed_email_subject": "你的 Formbricks 账户登录方式已被移除", + "sso_recovery_factors_removed_email_text": "你刚才首次使用单点登录进行了登录。由于你的邮箱地址之前从未被验证,我们移除了该账户之前设置的登录方式:", + "sso_recovery_factors_removed_email_two_factor": "双因素认证已被移除。", "survey_response_finished_email_congrats": "恭喜,您收到了一份新的问卷回复!有人刚刚完成了您的问卷:{surveyName}", "survey_response_finished_email_dont_want_notifications": "不希望收到这些通知?", "survey_response_finished_email_hey": "嗨 👋", diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json index 2b6f4095d7c1..afafe013fef6 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -644,6 +644,14 @@ "response_finished_email_subject": "{surveyName} 的回應已完成 ✅", "schedule_your_meeting": "安排你的會議", "select_a_date": "選擇日期", + "sso_recovery_factors_removed_email_did_not_expect": "如果你並未剛才使用單一登入方式登入,請立即聯繫你的管理員。", + "sso_recovery_factors_removed_email_heading": "已從你的帳號中移除登入方式", + "sso_recovery_factors_removed_email_password": "你的密碼已被移除。", + "sso_recovery_factors_removed_email_review_security": "檢視安全性設定", + "sso_recovery_factors_removed_email_sign_in_hint": "你可以繼續使用單一登入方式登入。若要再次使用密碼或雙重驗證,請在安全性設定中進行設定。", + "sso_recovery_factors_removed_email_subject": "已從你的 Formbricks 帳號中移除登入方式", + "sso_recovery_factors_removed_email_text": "你剛才首次使用單一登入方式登入。由於你的電子郵件地址從未經過驗證,我們已移除此帳號之前設定的登入方式:", + "sso_recovery_factors_removed_email_two_factor": "雙重驗證已被移除。", "survey_response_finished_email_congrats": "恭喜,您收到了新的問卷回應!有人剛完成您的問卷:{surveyName}", "survey_response_finished_email_dont_want_notifications": "不想收到這些通知?", "survey_response_finished_email_hey": "嗨 👋", diff --git a/apps/web/modules/auth/lib/better-auth-observability.ts b/apps/web/modules/auth/lib/better-auth-observability.ts index 694193c0289c..c505f258424e 100644 --- a/apps/web/modules/auth/lib/better-auth-observability.ts +++ b/apps/web/modules/auth/lib/better-auth-observability.ts @@ -340,7 +340,8 @@ const captureInternalAuthFault = ( * 1. OAuth callback rejections logged as a bare string code (`logger.error("account_not_linked")`, * `"unable_to_create_user"`, `"unable_to_get_user_info"`, … via `redirectOnError`) — no Error * object; these are client-facing redirects, e.g. our blocked-domain / SSO provisioning gate - * returning `false`. These were the top volume in Sentry (FORMBRICKS-16Q). + * rejecting a sign-up. These were the top volume in Sentry (FORMBRICKS-16Q); the SSO gate now + * rejects by throwing an APIError, which this branch skips by design (ENG-2537). * 2. Credential-path rejections thrown as a Better Auth `APIError` (`FAILED_TO_CREATE_USER`, * `USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL`, invalid-input codes) — a 4xx-equivalent response, not a * server fault. diff --git a/apps/web/modules/auth/lib/credential-signup-backstop.test.ts b/apps/web/modules/auth/lib/credential-signup-backstop.test.ts new file mode 100644 index 000000000000..b339393338ca --- /dev/null +++ b/apps/web/modules/auth/lib/credential-signup-backstop.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { SIGNUP_DISABLED_ERROR_CODE } from "@formbricks/types/errors"; +import { getIsFreshInstance } from "@/lib/instance/service"; +import { isSignupEmailDomainBlocked } from "@/modules/auth/lib/signup-email-domain"; +import { isSignupDomainAllowed } from "@/modules/auth/lib/signup-request-context"; +import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils"; +import { enforceCredentialSignupBackstop } from "./credential-signup-backstop"; + +vi.mock("@/modules/auth/lib/signup-email-domain", () => ({ isSignupEmailDomainBlocked: vi.fn() })); +vi.mock("@/modules/auth/lib/signup-request-context", () => ({ isSignupDomainAllowed: vi.fn() })); +vi.mock("@/lib/instance/service", () => ({ getIsFreshInstance: vi.fn() })); +vi.mock("@/modules/ee/license-check/lib/utils", () => ({ getIsMultiOrgEnabled: vi.fn() })); + +const constantsOverrides = vi.hoisted(() => ({ SIGNUP_ENABLED: true })); +vi.mock("@/lib/constants", () => ({ + get SIGNUP_ENABLED() { + return constantsOverrides.SIGNUP_ENABLED; + }, +})); + +beforeEach(() => { + vi.clearAllMocks(); + constantsOverrides.SIGNUP_ENABLED = true; + vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(true); + vi.mocked(getIsFreshInstance).mockResolvedValue(false); +}); + +/** + * The credential half of Better Auth's `user.create.before` hook, extracted from the SSO hooks module + * (ENG-2589). Behaviour is unchanged — these cases moved here with it, because this is where the policy + * they describe now lives. + * + * The return values are Better Auth's hook contract, so they are asserted literally: `false` blocks the + * insert silently, a throw blocks it with a surfaced error, `undefined` continues. + */ +describe("enforceCredentialSignupBackstop", () => { + test("blocks a sign-up that bypassed the action (raw /sign-up/email) with a blocked domain", async () => { + vi.mocked(isSignupDomainAllowed).mockReturnValue(false); // no action mark → direct native-endpoint POST + vi.mocked(isSignupEmailDomainBlocked).mockResolvedValue(true); + + expect(await enforceCredentialSignupBackstop("spammer@gmail.com")).toBe(false); + }); + + test("skips the domain re-check when the action already enforced it", async () => { + vi.mocked(isSignupDomainAllowed).mockReturnValue(true); // action marked the scope + vi.mocked(isSignupEmailDomainBlocked).mockResolvedValue(true); // would block, but must be skipped + + expect(await enforceCredentialSignupBackstop("spammer@gmail.com")).toBeUndefined(); + expect(isSignupEmailDomainBlocked).not.toHaveBeenCalled(); + }); + + test("allows an allowed domain on the raw endpoint", async () => { + vi.mocked(isSignupDomainAllowed).mockReturnValue(false); + vi.mocked(isSignupEmailDomainBlocked).mockResolvedValue(false); + + expect(await enforceCredentialSignupBackstop("person@acme-corp.com")).toBeUndefined(); + }); + + // ENG-2293: on a closed instance (SIGNUP_ENABLED=false, not fresh, multi-org disabled), a direct POST + // to Better Auth's native /sign-up/email must be blocked — this is the last line of defense, since + // the page and the server action both gate correctly. + describe("closed-instance policy", () => { + beforeEach(() => { + constantsOverrides.SIGNUP_ENABLED = false; + vi.mocked(getIsFreshInstance).mockResolvedValue(false); + vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false); + vi.mocked(isSignupDomainAllowed).mockReturnValue(false); // raw endpoint, not through the action + vi.mocked(isSignupEmailDomainBlocked).mockResolvedValue(false); // self-hosted: domain block is a no-op + }); + + test("blocks a raw sign-up on a closed instance", async () => { + // The stable code is the contract callers localize against; the message is display copy, so a + // reworded message must not fail this test and a dropped code must. + await expect(enforceCredentialSignupBackstop("intruder@example.com")).rejects.toMatchObject({ + status: "FORBIDDEN", + body: { code: SIGNUP_DISABLED_ERROR_CODE }, + }); + }); + + test("still allows the first administrator during fresh-instance setup", async () => { + vi.mocked(getIsFreshInstance).mockResolvedValue(true); + + expect(await enforceCredentialSignupBackstop("admin@example.com")).toBeUndefined(); + }); + + test("still allows a sign-up when public signup is open", async () => { + constantsOverrides.SIGNUP_ENABLED = true; + vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(true); + + expect(await enforceCredentialSignupBackstop("user@example.com")).toBeUndefined(); + }); + + test("still allows a sign-up that went through the action", async () => { + vi.mocked(isSignupDomainAllowed).mockReturnValue(true); // action marked the scope + + expect(await enforceCredentialSignupBackstop("user@example.com")).toBeUndefined(); + }); + }); +}); diff --git a/apps/web/modules/auth/lib/credential-signup-backstop.ts b/apps/web/modules/auth/lib/credential-signup-backstop.ts new file mode 100644 index 000000000000..1ad5c2b05f23 --- /dev/null +++ b/apps/web/modules/auth/lib/credential-signup-backstop.ts @@ -0,0 +1,43 @@ +import "server-only"; +import { isSignupEmailDomainBlocked } from "@/modules/auth/lib/signup-email-domain"; +import { isUninvitedSignupAllowed, signupDisabledError } from "@/modules/auth/lib/signup-policy"; +import { isSignupDomainAllowed } from "@/modules/auth/lib/signup-request-context"; + +/** + * The last-resort signup policy for CREDENTIAL user creation, enforced inside Better Auth's + * `user.create.before` database hook. + * + * It lives here rather than in the SSO hooks module because it is credential policy end to end — it + * shares nothing with the SSO sign-up flow except the hook slot both are wired into, and the SSO + * module's own concerns (provisioning gates, identity denormalization, the IdP's email claim) say + * nothing about it. + * + * Return values follow Better Auth's hook contract, which the caller passes through verbatim: + * `false` blocks the insert silently, a throw blocks it with an error the endpoint surfaces, and + * `undefined` continues with the defaults. + */ +export const enforceCredentialSignupBackstop = async (email: string): Promise => { + // createUserAction runs the full personal-email policy (Cloud gate + invite exemption) and marks the + // request scope before calling signUpEmail. If that mark is absent, this is a direct POST to Better + // Auth's native /sign-up/email — which bypasses the action — so re-enforce the domain block here (no + // invite is carried on that raw path). + // One read, two guards: both re-checks below exist only for a request that skipped the action. + const wentThroughAction = isSignupDomainAllowed(); + if (!wentThroughAction && (await isSignupEmailDomainBlocked(email, async () => false))) { + return false; + } + // ENG-2293 BACKSTOP: closed-instance policy (SIGNUP_ENABLED / multi-org / fresh-instance). + // The primary gate is `signupPolicyBeforeHandler` in auth.ts's `hooks.before`, which rejects + // `POST /sign-up/email` before Better Auth looks the address up — deliberately NOT here, + // because this hook only ever runs for an address that does not yet exist (the duplicate + // branch returns a synthetic 200 without creating anything), so rejecting here and nowhere + // else would answer "does this address have an account?". See signup-policy.ts. + // + // Kept anyway because this hook covers EVERY credential user-creation path, not just the one + // route the before-hook names: any future Better Auth plugin that creates a user (magic link, + // email OTP, admin create) lands here, and on a closed instance it should not. + if (!wentThroughAction && !(await isUninvitedSignupAllowed())) { + throw signupDisabledError(); + } + return undefined; // otherwise keep credential-signup defaults +}; diff --git a/apps/web/modules/ee/sso/lib/account-linking.ts b/apps/web/modules/ee/sso/lib/account-linking.ts index e67867654ac3..39613ac9aa5e 100644 --- a/apps/web/modules/ee/sso/lib/account-linking.ts +++ b/apps/web/modules/ee/sso/lib/account-linking.ts @@ -11,6 +11,10 @@ export const LINKED_SSO_LOOKUP_SELECT = { isActive: true, identityProvider: true, identityProviderAccountId: true, + // Read before the recovery strip nulls it: 2FA lives in two stores, and a user who enrolled before + // the backfill shim landed has this legacy column set with no `TwoFactor` row to count. Without it, + // both the audit record and the notification mail would report that nothing was taken from them. + twoFactorEnabled: true, } as const; export type TSsoLookupUser = Prisma.UserGetPayload<{ diff --git a/apps/web/modules/ee/sso/lib/better-auth-azuread-email-verified.test.ts b/apps/web/modules/ee/sso/lib/better-auth-azuread-email-verified.test.ts new file mode 100644 index 000000000000..1970ccfb1cdc --- /dev/null +++ b/apps/web/modules/ee/sso/lib/better-auth-azuread-email-verified.test.ts @@ -0,0 +1,238 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +/** + * ENG-2589 — the Entra half, driven through a REAL Better Auth instance. + * + * Azure is the provider whose classification is easiest to get wrong, because Microsoft does not speak + * `email_verified` at all: neither its id_tokens nor Graph's `/oidc/userinfo` carry it. Our mapper falls + * back to `xms_edov`, Microsoft's own "is this address proven" claim — and the whole chain that has to + * hold for that to matter is long: Graph's response has to reach `mapProfileToUser` with the raw claim + * intact (it survives only via a spread in `microsoftGraphUserInfo`), the mapper's return has to win + * over Better Auth's own `emailVerified`, and the sign-up hook has to pass it through rather than + * forcing `true`. A unit test on the mapper proves one link of that; this proves the row. + * + * Both Azure branches are covered, because they resolve identity in completely different ways: + * - `common` / unset — the documented default: explicit endpoints, no discovery, identity from Graph. + * - a concrete tenant: discovery, and identity from the id_token claims. + */ +const BASE_URL = "https://app.formbricks.test"; +const GRAPH = "https://graph.microsoft.com/oidc/userinfo"; + +const { captureSsoIdentity } = vi.hoisted(() => ({ captureSsoIdentity: vi.fn() })); +const { runWithSsoRequestContext } = await import("./sso-request-context"); +vi.mock("./sso-request-context", async () => { + const actual = await vi.importActual("./sso-request-context"); + return { ...actual, captureSsoIdentity }; +}); + +// The gate and the membership writes have their own suites; here they must only let the sign-up reach +// the database, which is what this file asserts on. +vi.mock("./sso-provisioning", () => ({ + gateSsoProvisioning: vi.fn(async () => ({ + action: "provision", + organizationId: "org-1", + assignToDefaultTeam: false, + signupSource: "direct", + })), + provisionSsoUserMemberships: vi.fn(), +})); +vi.mock("./sso-recovery", () => ({ startSsoRecovery: vi.fn() })); +vi.mock("@formbricks/database", () => ({ prisma: { user: { findUnique: vi.fn() } } })); +vi.mock("@/lib/posthog", () => ({ identifyPostHogPerson: vi.fn() })); +vi.mock("@/lib/utils/locale", () => ({ findMatchingLocale: vi.fn(async () => "en-US") })); +vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({ + queueAuditEventBackground: vi.fn(async () => undefined), +})); +vi.mock("next/headers", () => ({ cookies: vi.fn(async () => new Map()) })); +vi.mock("@/lib/env", async () => { + const actual = await vi.importActual<{ env: Record }>("@/lib/env"); + return { env: { ...actual.env, BETTER_AUTH_URL: BASE_URL } }; +}); + +const loadAzure = async (tenant?: string) => { + vi.resetModules(); + vi.doMock("@/lib/constants", async () => { + const actual = await vi.importActual>("@/lib/constants"); + return { + ...actual, + ENTERPRISE_LICENSE_KEY: "lic", + WEBAPP_URL: BASE_URL, + GITHUB_OAUTH_ENABLED: false, + GOOGLE_OAUTH_ENABLED: false, + OIDC_OAUTH_ENABLED: false, + SAML_OAUTH_ENABLED: false, + AZURE_OAUTH_ENABLED: true, + AZUREAD_CLIENT_ID: "az-client", + AZUREAD_CLIENT_SECRET: "az-secret", + AZUREAD_TENANT_ID: tenant, + }; + }); + const providers = await import("./better-auth-providers"); + const hooks = await import("./better-auth-hooks"); + return { config: providers.ssoGenericOAuthConfig, hooks: hooks.ssoDatabaseHooks }; +}; + +/** The Entra profile under test. `undefined` omits the claim, which is the default-tenant reality. */ +const buildProfile = (email: string, xmsEdov: boolean | undefined) => ({ + sub: `sub-${email}`, + email, + name: "Entra User", + ...(xmsEdov === undefined ? {} : { xms_edov: xmsEdov }), +}); + +/** An UNSIGNED id_token, for the concrete-tenant branch where identity comes from its claims. */ +const idTokenFor = (profile: Record) => + `${Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url")}.${Buffer.from( + JSON.stringify(profile) + ).toString("base64url")}.`; + +const signUpThroughAzure = async ({ + tenant, + email, + xmsEdov, +}: { + tenant?: string; + email: string; + xmsEdov: boolean | undefined; +}) => { + const profile = buildProfile(email, xmsEdov); + const usesDiscovery = Boolean(tenant && !["common", "organizations"].includes(tenant)); + + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + + // Discovery, only reached on the concrete-tenant branch. No `jwks_uri`: with one present Better + // Auth would demand a verifiable id_token signature, which a stub cannot produce. + if (url.includes("/.well-known/openid-configuration")) { + const authority = `https://login.microsoftonline.com/${tenant}`; + return Response.json({ + issuer: `${authority}/v2.0`, + authorization_endpoint: `${authority}/oauth2/v2.0/authorize`, + token_endpoint: `${authority}/oauth2/v2.0/token`, + userinfo_endpoint: GRAPH, + }); + } + if (url.includes("/oauth2/v2.0/token")) { + return Response.json({ + access_token: "az-access-token", + token_type: "Bearer", + expires_in: 3600, + // Only the discovery branch reads identity from the token; the Graph override ignores it. + ...(usesDiscovery ? { id_token: idTokenFor(profile) } : {}), + }); + } + if (url.startsWith(GRAPH)) return Response.json(profile); + throw new Error(`unexpected outbound fetch: ${url}`); + }) + ); + + const { config, hooks } = await loadAzure(tenant); + const { betterAuth } = await import("better-auth"); + const { memoryAdapter } = await import("better-auth/adapters/memory"); + const { genericOAuth } = await import("better-auth/plugins"); + + const db: Record[]> = { + user: [], + session: [], + account: [], + verification: [], + }; + const auth = betterAuth({ + baseURL: BASE_URL, + secret: "eng-2589-azuread-email-verified-secret", + database: memoryAdapter(db), + user: { + additionalFields: { + identityProvider: { type: "string", required: false, input: false }, + identityProviderAccountId: { type: "string", required: false, input: false }, + }, + }, + plugins: [genericOAuth({ config })], + databaseHooks: hooks, + }); + + const signIn = await auth.handler( + new Request(`${BASE_URL}/api/auth/sign-in/social`, { + method: "POST", + headers: { "content-type": "application/json", origin: BASE_URL }, + body: JSON.stringify({ provider: "azuread", callbackURL: "/" }), + }) + ); + expect(signIn.status).toBe(200); + const { url } = (await signIn.json()) as { url: string }; + const state = new URL(url).searchParams.get("state") ?? ""; + expect(state).not.toBe(""); + const cookie = (signIn.headers.getSetCookie?.() ?? []).map((c) => c.split(";")[0]).join("; "); + + // Entra's redirect back, replayed directly — it is only a browser redirect, and its host is not ours. + await runWithSsoRequestContext(() => + auth.handler( + new Request(`${BASE_URL}/api/auth/callback/azuread?code=az-code&state=${state}`, { + headers: { cookie }, + }) + ) + ); + + expect(db.user, "the callback did not create a user — the stub is wrong, not the claim").toHaveLength(1); + return db.user[0]; +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.doUnmock("@/lib/constants"); +}); + +describe("Entra sign-up honours xms_edov (real Better Auth, ENG-2589)", () => { + // `common` is the documented default and what an unset AZUREAD_TENANT_ID resolves to, so it is the + // configuration most self-hosters actually run. Identity comes from Graph here, not the id_token. + describe.each([ + { tenant: undefined, label: "unset" }, + { tenant: "common", label: "common" }, + ])("multi-tenant authority ($label) — identity from Graph", ({ tenant, label }) => { + test("an address Entra says is NOT domain-verified does not become a verified account", async () => { + const user = await signUpThroughAzure({ tenant, email: `denied-${label}@corp.test`, xmsEdov: false }); + expect(user).toMatchObject({ emailVerified: false, identityProvider: "azuread" }); + }); + + test("an address Entra vouches for stays verified", async () => { + const user = await signUpThroughAzure({ tenant, email: `ok-${label}@corp.test`, xmsEdov: true }); + expect(user).toMatchObject({ emailVerified: true, identityProvider: "azuread" }); + }); + + /** + * The upgrade-safety case, and the common one in the wild: `xms_edov` is an OPTIONAL claim a tenant + * has to switch on, so most Entra deployments send neither it nor `email_verified`. Those must keep + * producing verified users exactly as they did before this change. + */ + test("a tenant that enables no verification claim is unchanged", async () => { + const user = await signUpThroughAzure({ + tenant, + email: `quiet-${label}@corp.test`, + xmsEdov: undefined, + }); + expect(user).toMatchObject({ emailVerified: true, identityProvider: "azuread" }); + }); + }); + + // A concrete tenant takes the discovery branch instead, where identity is read from the id_token's + // claims rather than Graph — a different code path that must reach the same answer. + describe("concrete tenant — identity from the id_token", () => { + const tenant = "00000000-1111-2222-3333-444444444444"; + + test("a denied xms_edov is honoured on the discovery branch too", async () => { + const user = await signUpThroughAzure({ tenant, email: "denied-tenant@corp.test", xmsEdov: false }); + expect(user).toMatchObject({ emailVerified: false, identityProvider: "azuread" }); + }); + + test("an absent claim still verifies on the discovery branch", async () => { + const user = await signUpThroughAzure({ tenant, email: "quiet-tenant@corp.test", xmsEdov: undefined }); + expect(user).toMatchObject({ emailVerified: true, identityProvider: "azuread" }); + }); + }); +}); diff --git a/apps/web/modules/ee/sso/lib/better-auth-hooks.test.ts b/apps/web/modules/ee/sso/lib/better-auth-hooks.test.ts index 17b41b53312a..cc04fad813d7 100644 --- a/apps/web/modules/ee/sso/lib/better-auth-hooks.test.ts +++ b/apps/web/modules/ee/sso/lib/better-auth-hooks.test.ts @@ -1,17 +1,12 @@ import { getOAuthState } from "better-auth/api"; -import { beforeEach, describe, expect, test, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; -import { SIGNUP_DISABLED_ERROR_CODE, SIGNUP_EMAIL_DOMAIN_BLOCKED_ERROR_CODE } from "@formbricks/types/errors"; -import { getIsFreshInstance } from "@/lib/instance/service"; +import { SIGNUP_EMAIL_DOMAIN_BLOCKED_ERROR_CODE } from "@formbricks/types/errors"; import { identifyPostHogPerson } from "@/lib/posthog"; import { findMatchingLocale } from "@/lib/utils/locale"; -import { isSignupEmailDomainBlocked } from "@/modules/auth/lib/signup-email-domain"; -import { isSignupDomainAllowed } from "@/modules/auth/lib/signup-request-context"; -import { - getIsMultiOrgEnabled, - getIsSamlSsoEnabled, - getIsSsoEnabled, -} from "@/modules/ee/license-check/lib/utils"; +import { enforceCredentialSignupBackstop } from "@/modules/auth/lib/credential-signup-backstop"; +import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler"; +import { getIsSamlSsoEnabled, getIsSsoEnabled } from "@/modules/ee/license-check/lib/utils"; import { blockedSignupDomainRedirectAfter, getSsoProviderFromContext, @@ -30,24 +25,26 @@ import { setSsoSignupRejectReason, } from "./sso-request-context"; -vi.mock("better-auth/api", () => ({ +// Spread the REAL module: only `getOAuthState` and the middleware wrapper need stubbing. A hand-rolled +// `APIError` class cannot stand in here — Better Auth identifies one by `instanceof` or `name === +// "APIError"`, and a local subclass of Error reports `name === "Error"`, so every `isAPIError` branch +// the reject now depends on would take the wrong path while these tests still passed. +vi.mock("better-auth/api", async (importOriginal) => ({ + ...(await importOriginal()), getOAuthState: vi.fn(), // Passthrough so the wrapped hook is testable directly as its inner function. createAuthMiddleware: (fn: unknown) => fn, - // Keeps `body` like the real APIError does, so a test can assert the stable error CODE rather than - // the English message — dropping it would leave the message as the only assertable thing, which is - // display copy, not the contract callers depend on. - APIError: class APIError extends Error { - status: string; - body?: { message?: string; code?: string }; - constructor(status: string, body?: { message?: string; code?: string }) { - super(body?.message); - this.status = status; - this.body = body; - } - }, })); vi.mock("@formbricks/database", () => ({ prisma: { user: { findUnique: vi.fn() } } })); +// The unverified-sign-up signal (ENG-2589) is asserted through both of its channels. +const { loggerWarn, loggerError, loggerWithContext } = vi.hoisted(() => { + const warn = vi.fn(); + return { loggerWarn: warn, loggerError: vi.fn(), loggerWithContext: vi.fn(() => ({ warn })) }; +}); +vi.mock("@formbricks/logger", () => ({ + logger: { withContext: loggerWithContext, warn: loggerWarn, error: loggerError }, +})); +vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({ queueAuditEventBackground: vi.fn() })); vi.mock("@/lib/posthog", () => ({ identifyPostHogPerson: vi.fn() })); vi.mock("@/lib/utils/locale", () => ({ findMatchingLocale: vi.fn() })); vi.mock("./sso-provisioning", () => ({ @@ -55,19 +52,14 @@ vi.mock("./sso-provisioning", () => ({ provisionSsoUserMemberships: vi.fn(), })); vi.mock("./sso-recovery", () => ({ startSsoRecovery: vi.fn() })); -vi.mock("@/modules/auth/lib/signup-email-domain", () => ({ isSignupEmailDomainBlocked: vi.fn() })); -vi.mock("@/modules/auth/lib/signup-request-context", () => ({ isSignupDomainAllowed: vi.fn() })); - -const constantsOverrides = vi.hoisted(() => ({ SIGNUP_ENABLED: true })); -vi.mock("@/lib/constants", () => ({ - WEBAPP_URL: "http://localhost:3000", - get SIGNUP_ENABLED() { - return constantsOverrides.SIGNUP_ENABLED; - }, +// The credential policy is its own module now (credential-signup-backstop.test.ts covers it); here it +// is mocked so the hook's routing to it can be asserted without re-testing the policy itself. +vi.mock("@/modules/auth/lib/credential-signup-backstop", () => ({ + enforceCredentialSignupBackstop: vi.fn(), })); -vi.mock("@/lib/instance/service", () => ({ getIsFreshInstance: vi.fn() })); + +vi.mock("@/lib/constants", () => ({ WEBAPP_URL: "http://localhost:3000" })); vi.mock("@/modules/ee/license-check/lib/utils", () => ({ - getIsMultiOrgEnabled: vi.fn(), getIsSsoEnabled: vi.fn(), getIsSamlSsoEnabled: vi.fn(), })); @@ -84,14 +76,15 @@ const provisionDecision = { beforeEach(() => { vi.clearAllMocks(); - constantsOverrides.SIGNUP_ENABLED = true; vi.mocked(findMatchingLocale).mockResolvedValue("en-US"); vi.mocked(getOAuthState).mockResolvedValue({ callbackURL: "/" } as never); vi.mocked(gateSsoProvisioning).mockResolvedValue(provisionDecision); vi.mocked(getIsSsoEnabled).mockResolvedValue(true); vi.mocked(getIsSamlSsoEnabled).mockResolvedValue(true); - vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(true); - vi.mocked(getIsFreshInstance).mockResolvedValue(false); + vi.mocked(enforceCredentialSignupBackstop).mockResolvedValue(undefined); + // The real helper is async and returns a promise the caller attaches a `.catch` to; a bare `vi.fn()` + // would return undefined and make that call throw for reasons the code under test never causes. + vi.mocked(queueAuditEventBackground).mockResolvedValue(undefined); vi.mocked(prisma.user.findUnique).mockResolvedValue({ id: "u1", email: "a@b.com", @@ -126,13 +119,32 @@ describe("getSsoProviderFromContext", () => { describe("ssoDatabaseHooks.user.create.before", () => { const before = ssoDatabaseHooks.user!.create!.before!; - test("rejects (returns false) when the provisioning gate rejects", async () => { + /** + * ENG-2537. The documented way to reject here is `return false`, and it is what shipped — but Better + * Auth then resolves `createUser` to `null` and reads `createdUser.id` off it one line later, which + * 500s every rejected SSO sign-up. It must throw an APIError carrying a `code` instead: that is the + * shape the OAuth callback catches and converts into a redirect. + */ + test("throws an APIError carrying the reason code when the provisioning gate rejects", async () => { vi.mocked(gateSsoProvisioning).mockResolvedValue({ action: "reject", reason: "missing_callback_url" }); - const result = await runWithSsoRequestContext(() => - before({ id: "u1", email: "a@b.com" } as never, callbackCtx as never) - ); - expect(result).toBe(false); - expect(getSsoProvisioningDecision()).toBeUndefined(); + + await expect( + runWithSsoRequestContext(() => before({ id: "u1", email: "a@b.com" } as never, callbackCtx as never)) + // The `code` is load-bearing: `callback.mjs` only redirects for an APIError that carries one, and + // rethrows otherwise — which is the 500 this replaces. + ).rejects.toMatchObject({ status: "FORBIDDEN", body: { code: "missing_callback_url" } }); + }); + + test("does not stash a provisioning decision for a rejected sign-up", async () => { + vi.mocked(gateSsoProvisioning).mockResolvedValue({ action: "reject", reason: "missing_callback_url" }); + let stashed: ReturnType; + + await runWithSsoRequestContext(async () => { + await before({ id: "u1", email: "a@b.com" } as never, callbackCtx as never).catch(() => undefined); + stashed = getSsoProvisioningDecision(); + }); + + expect(stashed).toBeUndefined(); }); test("stashes the reject reason so the after-hook can redirect (personal email domain)", async () => { @@ -141,12 +153,14 @@ describe("ssoDatabaseHooks.user.create.before", () => { reason: SIGNUP_EMAIL_DOMAIN_BLOCKED_ERROR_CODE, }); let reason: string | undefined; - const result = await runWithSsoRequestContext(async () => { - const r = await before({ id: "u1", email: "spammer@gmail.com" } as never, callbackCtx as never); + await runWithSsoRequestContext(async () => { + // The throw is the reject; the stashed reason is what the after-hook reads to rewrite the + // redirect Better Auth produces from it. + await before({ id: "u1", email: "spammer@gmail.com" } as never, callbackCtx as never).catch( + () => undefined + ); reason = getSsoSignupRejectReason(); - return r; }); - expect(result).toBe(false); expect(reason).toBe(SIGNUP_EMAIL_DOMAIN_BLOCKED_ERROR_CODE); }); @@ -161,7 +175,12 @@ describe("ssoDatabaseHooks.user.create.before", () => { test("on provision: verifies email, denormalizes provider, sets locale + name fallback, and stashes the decision", async () => { let stashed: ReturnType; const result = await runWithSsoRequestContext(async () => { - const r = await before({ id: "u1", email: "john.doe@example.com" } as never, callbackCtx as never); + // `emailVerified: true` is what the openid mapper resolves for an IdP that omits the claim — the + // common path — so this stays a test about the other four enrichment fields. + const r = await before( + { id: "u1", email: "john.doe@example.com", emailVerified: true } as never, + callbackCtx as never + ); stashed = getSsoProvisioningDecision(); return r; }); @@ -176,7 +195,10 @@ describe("ssoDatabaseHooks.user.create.before", () => { test("normalizes a clean provider-supplied name (unchanged, no fallback) when present", async () => { const result = await runWithSsoRequestContext(() => - before({ id: "u1", email: "a@b.com", name: "Ada Lovelace" } as never, callbackCtx as never) + before( + { id: "u1", email: "a@b.com", name: "Ada Lovelace", emailVerified: true } as never, + callbackCtx as never + ) ); expect(result).toEqual({ data: { emailVerified: true, identityProvider: "openid", locale: "en-US", name: "Ada Lovelace" }, @@ -216,103 +238,89 @@ describe("ssoDatabaseHooks.user.create.before", () => { expect(result).toMatchObject({ data: { name: "User" } }); }); - test("leaves email/password sign-ups untouched (gate not run)", async () => { - const result = await before({ id: "u1", email: "a@b.com" } as never, { path: "/sign-up/email" } as never); - expect(result).toBeUndefined(); - expect(gateSsoProvisioning).not.toHaveBeenCalled(); - }); + // ENG-2589: Better Auth computes the IdP's own `emailVerified` answer BEFORE this hook runs — + // google reads `email_verified` from the id_token, github from /user/emails — and hands it to the + // hook as `user.emailVerified`; whatever the hook returns is shallow-merged over it. Overwriting + // with `true` mints a verified account for an address the IdP itself calls unproven (account + // squatting: `reclaimUnverifiedLocalAuthIfNeeded` skips verified users, and an org invite to that + // address lands on the squatter's account). + describe("emailVerified follows the IdP's claim where it is a real signal (ENG-2589)", () => { + const socialCtx = (id: string) => ({ path: "/callback/:id", params: { id } }); - test("blocks a credential sign-up that bypassed the action (raw /sign-up/email) with a blocked domain", async () => { - vi.mocked(isSignupDomainAllowed).mockReturnValue(false); // no action mark → direct native-endpoint POST - vi.mocked(isSignupEmailDomainBlocked).mockResolvedValue(true); - const result = await before( - { id: "u1", email: "spammer@gmail.com" } as never, - { - path: "/sign-up/email", - } as never - ); - expect(result).toBe(false); - expect(gateSsoProvisioning).not.toHaveBeenCalled(); - }); - - test("allows a credential sign-up that went through the action (domain already enforced, hook skips)", async () => { - vi.mocked(isSignupDomainAllowed).mockReturnValue(true); // action marked the scope - vi.mocked(isSignupEmailDomainBlocked).mockResolvedValue(true); // would block, but must be skipped - const result = await before( - { id: "u1", email: "spammer@gmail.com" } as never, - { - path: "/sign-up/email", - } as never - ); - expect(result).toBeUndefined(); - expect(isSignupEmailDomainBlocked).not.toHaveBeenCalled(); - }); - - test("allows a credential sign-up with an allowed domain on the raw endpoint", async () => { - vi.mocked(isSignupDomainAllowed).mockReturnValue(false); - vi.mocked(isSignupEmailDomainBlocked).mockResolvedValue(false); - const result = await before( - { id: "u1", email: "person@acme-corp.com" } as never, - { - path: "/sign-up/email", - } as never - ); - expect(result).toBeUndefined(); - }); - - // ENG-2293: on a closed instance (SIGNUP_ENABLED=false, not fresh, multi-org disabled), - // a direct POST to Better Auth's native /sign-up/email must be blocked — the hook is the - // last line of defense, since the page and the server action both gate correctly. - describe("closed-instance policy", () => { - beforeEach(() => { - constantsOverrides.SIGNUP_ENABLED = false; - vi.mocked(getIsFreshInstance).mockResolvedValue(false); - vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false); + test.each(["google", "github"])("%s: an IdP-unverified email is not marked verified", async (id) => { + const result = await runWithSsoRequestContext(() => + before({ id: "u1", email: "a@b.com", emailVerified: false } as never, socialCtx(id) as never) + ); + expect(result).toMatchObject({ data: { emailVerified: false } }); }); - test("blocks a raw credential sign-up on a closed instance", async () => { - vi.mocked(isSignupDomainAllowed).mockReturnValue(false); // raw endpoint, not through the action - vi.mocked(isSignupEmailDomainBlocked).mockResolvedValue(false); // self-hosted: domain block is a no-op - await expect( - before({ id: "u1", email: "intruder@example.com" } as never, { path: "/sign-up/email" } as never) - // The stable code is the contract callers localize against; the message is display copy, so a - // reworded message must not fail this test and a dropped code must. - ).rejects.toMatchObject({ status: "FORBIDDEN", body: { code: SIGNUP_DISABLED_ERROR_CODE } }); - expect(gateSsoProvisioning).not.toHaveBeenCalled(); + test.each(["google", "github"])("%s: an IdP-verified email stays verified", async (id) => { + const result = await runWithSsoRequestContext(() => + before({ id: "u1", email: "a@b.com", emailVerified: true } as never, socialCtx(id) as never) + ); + expect(result).toMatchObject({ data: { emailVerified: true } }); }); - test("still allows the first administrator during fresh-instance setup", async () => { - vi.mocked(getIsFreshInstance).mockResolvedValue(true); - vi.mocked(isSignupDomainAllowed).mockReturnValue(false); - vi.mocked(isSignupEmailDomainBlocked).mockResolvedValue(false); - const result = await before( - { id: "u1", email: "admin@example.com" } as never, - { path: "/sign-up/email" } as never + // The generic providers are `raw-claim`: their mapProfileToUser has already resolved the raw + // `email_verified` (absent → true, asserted false → false) and that value reaches the hook as + // `user.emailVerified`, so the hook passes it through rather than deciding again. Asserting the + // pass-through here is what stops a future edit re-introducing a blanket `true` that would + // silently discard the mapper's answer — the claim-reading itself is covered in + // better-auth-providers.test.ts and end to end in better-auth-oidc-email-verified.test.ts. + test.each(["openid", "azuread"])("%s: passes the mapper-resolved claim through", async (id) => { + const genericCtx = { path: "/callback/:providerId", params: { providerId: id } }; + const denied = await runWithSsoRequestContext(() => + before({ id: "u1", email: "a@b.com", emailVerified: false } as never, genericCtx as never) ); - expect(result).toBeUndefined(); - }); + expect(denied).toMatchObject({ data: { emailVerified: false } }); - test("still allows a credential sign-up when public signup is open", async () => { - constantsOverrides.SIGNUP_ENABLED = true; - vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(true); - vi.mocked(isSignupDomainAllowed).mockReturnValue(false); - vi.mocked(isSignupEmailDomainBlocked).mockResolvedValue(false); - const result = await before( - { id: "u1", email: "user@example.com" } as never, - { path: "/sign-up/email" } as never + const attested = await runWithSsoRequestContext(() => + before({ id: "u1", email: "a@b.com", emailVerified: true } as never, genericCtx as never) ); - expect(result).toBeUndefined(); + expect(attested).toMatchObject({ data: { emailVerified: true } }); }); - test("still allows a credential sign-up via the action (domain already enforced)", async () => { - vi.mocked(isSignupDomainAllowed).mockReturnValue(true); // action marked the scope - const result = await before( - { id: "u1", email: "user@example.com" } as never, - { path: "/sign-up/email" } as never + // SAML is `never-attests`: BoxyHQ carries no `email_verified` on any path, so there is no claim to + // honour and the row is verified as it always has been — whatever value happens to arrive. + test("saml: stays verified, because no claim can exist", async () => { + const result = await runWithSsoRequestContext(() => + before( + { id: "u1", email: "a@b.com", emailVerified: false } as never, + { path: "/callback/:providerId", params: { providerId: "saml" } } as never + ) ); - expect(result).toBeUndefined(); + expect(result).toMatchObject({ data: { emailVerified: true } }); }); }); + + /** + * The credential branch now delegates to `enforceCredentialSignupBackstop` + * (modules/auth/lib/credential-signup-backstop.ts), where its own suite covers the domain block and + * the ENG-2293 closed-instance policy in detail. What matters HERE is the routing: a non-SSO context + * must reach that policy rather than the SSO gate, and its answer must be passed through unaltered — + * Better Auth reads the return value literally, so a hook that swallowed a `false` would silently + * re-open every path the backstop closes. + */ + test.each([ + { verdict: false as const, label: "a block" }, + { verdict: undefined, label: "an allow" }, + ])("routes a credential sign-up to the backstop and passes $label through", async ({ verdict }) => { + vi.mocked(enforceCredentialSignupBackstop).mockResolvedValue(verdict); + + const result = await before({ id: "u1", email: "a@b.com" } as never, { path: "/sign-up/email" } as never); + + expect(result).toBe(verdict); + expect(enforceCredentialSignupBackstop).toHaveBeenCalledWith("a@b.com"); + expect(gateSsoProvisioning).not.toHaveBeenCalled(); + }); + + test("propagates a backstop rejection instead of creating the user", async () => { + vi.mocked(enforceCredentialSignupBackstop).mockRejectedValue(new Error("signup disabled")); + + await expect( + before({ id: "u1", email: "a@b.com" } as never, { path: "/sign-up/email" } as never) + ).rejects.toThrow("signup disabled"); + }); }); describe("ssoDatabaseHooks.user.create.after", () => { @@ -343,6 +351,89 @@ describe("ssoDatabaseHooks.user.create.after", () => { expect(provisionSsoUserMemberships).not.toHaveBeenCalled(); }); + /** + * ENG-2589. An account created for an address the IdP would not vouch for is the state this fix + * allows to exist, so it has to be visible: an audit event for the security trail, and a `warn` log + * for the self-hosters who have no audit log (it is enterprise-gated and off by default). + */ + describe("observability for an IdP-unverified sign-up", () => { + const runAfter = (emailVerified: boolean) => + runWithSsoRequestContext(async () => { + setSsoProvisioningDecision(provisionDecision); + await after({ id: "u1", email: "a@b.com", emailVerified } as never, callbackCtx as never); + }); + + test("emits a warn log and an audit event naming the provider", async () => { + await runAfter(false); + + // `userId` is on the log line too: the audit event is enterprise-gated, so for a self-hoster this + // is the only channel, and without an identifier it names no account to go and look at. + expect(loggerWithContext).toHaveBeenCalledWith({ + source: "sso-signup", + ssoProvider: "openid", + userId: "u1", + emailVerified: false, + }); + expect(loggerWarn).toHaveBeenCalledTimes(1); + expect(queueAuditEventBackground).toHaveBeenCalledWith( + expect.objectContaining({ + action: "updated", + targetType: "user", + userId: "u1", + targetId: "u1", + organizationId: "org-1", + status: "success", + newObject: { ssoUnverifiedSignupMarker: true, provider: "openid", emailVerified: false }, + }) + ); + }); + + // A sign-up can provision no organization at all, and the audit schema takes no null there. + test("falls back to the unknown-organization marker when none was provisioned", async () => { + await runWithSsoRequestContext(async () => { + setSsoProvisioningDecision({ ...provisionDecision, organizationId: null }); + await after({ id: "u1", email: "a@b.com", emailVerified: false } as never, callbackCtx as never); + }); + + expect(queueAuditEventBackground).toHaveBeenCalledWith( + expect.objectContaining({ organizationId: "unknown" }) + ); + }); + + // The verified case is the overwhelming majority; emitting for it would bury the signal. + test("says nothing for a verified sign-up", async () => { + await runAfter(true); + + expect(loggerWarn).not.toHaveBeenCalled(); + expect(queueAuditEventBackground).not.toHaveBeenCalled(); + }); + + test("provisions the user either way", async () => { + await runAfter(false); + + expect(provisionSsoUserMemberships).toHaveBeenCalledTimes(1); + }); + + // Observability must never be the reason a sign-up fails: this runs post-commit, so a throw here + // would surface as a broken callback on an account that already exists. + test.each([ + { + label: "throws synchronously", + impl: () => { + throw new Error("audit sink unavailable"); + }, + }, + // The call is not awaited, so a rejection has to be caught on the promise or it escapes the + // try/catch entirely and lands as an unhandled rejection. + { label: "rejects asynchronously", impl: async () => Promise.reject(new Error("audit sink down")) }, + ])("a failing audit sink that $label does not break the sign-up", async ({ impl }) => { + vi.mocked(queueAuditEventBackground).mockImplementation(impl as never); + + await expect(runAfter(false)).resolves.not.toThrow(); + expect(provisionSsoUserMemberships).toHaveBeenCalledTimes(1); + }); + }); + test("before → after carries the decision end-to-end", async () => { await runWithSsoRequestContext(async () => { await before({ id: "u1", email: "a@b.com" } as never, callbackCtx as never); @@ -537,3 +628,143 @@ describe("blockedSignupDomainRedirectAfter", () => { expect(redirect).not.toHaveBeenCalled(); }); }); + +/** + * ENG-2589 — the framework boundary the unit tests above cannot see. Better Auth derives + * `emailVerified` from GitHub's /user/emails lookup (`verified ?? false`) before `user.create.before` + * runs, hands it to the hook in its `user` argument, and `createWithHooks` shallow-merges the hook's + * return over it. These drive a REAL Better Auth instance through the full sign-in → callback flow — + * with `fetch` stubbed at the GitHub boundary — and assert the row that is actually persisted, so a + * change in the provider's signal derivation or in the hook-merge order fails here, not in production. + * + * The github registration is deliberately minimal: production's `mapProfileToUser` returns only + * `{ email }` (better-auth-providers.ts), so Better Auth's own emailVerified survives the profile + * mapping identically in both shapes. + */ +describe("SSO sign-up persists the IdP's email_verified claim (real Better Auth, ENG-2589)", () => { + const BASE_URL = "https://app.formbricks.test"; + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const signUpThroughGithub = async (verifiedAtIdp: boolean) => { + const { betterAuth } = await import("better-auth"); + const { memoryAdapter } = await import("better-auth/adapters/memory"); + const db: Record[]> = { + user: [], + session: [], + account: [], + verification: [], + }; + const auth = betterAuth({ + baseURL: BASE_URL, + secret: "eng-2589-email-verified-boundary-secret", + database: memoryAdapter(db), + user: { + // The two fields the account.create.after hook denormalizes (parity with auth.ts). + additionalFields: { + identityProvider: { type: "string", required: false, input: false }, + identityProviderAccountId: { type: "string", required: false, input: false }, + }, + }, + socialProviders: { github: { clientId: "gh-id", clientSecret: "gh-secret" } }, + databaseHooks: ssoDatabaseHooks, + }); + + // Leg 1: sign-in issues the authorization URL, the state row, and its signed cookie. + const signIn = await auth.handler( + new Request(`${BASE_URL}/api/auth/sign-in/social`, { + method: "POST", + headers: { "content-type": "application/json", origin: BASE_URL }, + body: JSON.stringify({ provider: "github", callbackURL: "/" }), + }) + ); + expect(signIn.status).toBe(200); + const { url } = (await signIn.json()) as { url: string }; + const state = new URL(url).searchParams.get("state") ?? ""; + expect(state).not.toBe(""); + const cookie = (signIn.headers.getSetCookie?.() ?? []).map((v) => v.split(";")[0]).join("; "); + + // Leg 2: the callback, with GitHub's token + profile + emails endpoints stubbed. The /user/emails + // `verified` flag is the IdP's actual attestation — the value under test. + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request) => { + const requested = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (requested.startsWith("https://github.com/login/oauth/access_token")) { + return Response.json({ access_token: "gh-token", token_type: "bearer" }); + } + if (requested.startsWith("https://api.github.com/user/emails")) { + return Response.json([{ email: "squatter@corp.test", primary: true, verified: verifiedAtIdp }]); + } + if (requested.startsWith("https://api.github.com/user")) { + return Response.json({ + id: 4242, + login: "squatter", + name: "Squatter", + email: "squatter@corp.test", + }); + } + throw new Error(`unexpected outbound fetch: ${requested}`); + }) + ); + + const callback = await runWithSsoRequestContext(() => + auth.handler( + new Request(`${BASE_URL}/api/auth/callback/github?code=gh-code&state=${state}`, { + headers: { cookie }, + }) + ) + ); + + // Anchor the flow itself: a rejected callback would leave db.user empty and a bare + // `toMatchObject` on undefined would blame the wrong thing. + expect(callback.status).toBeGreaterThanOrEqual(300); + return { db, callback }; + }; + + /** The sign-up cases expect exactly one row; a reject case asserts on `db` directly. */ + const signUpUserThroughGithub = async (verifiedAtIdp: boolean) => { + const { db } = await signUpThroughGithub(verifiedAtIdp); + expect(db.user).toHaveLength(1); + return db.user[0]; + }; + + test("github reporting the address as unverified must not mint a verified account", async () => { + const user = await signUpUserThroughGithub(false); + expect(user).toMatchObject({ email: "squatter@corp.test", emailVerified: false }); + }); + + test("github reporting the address as verified keeps it verified", async () => { + const user = await signUpUserThroughGithub(true); + expect(user).toMatchObject({ email: "squatter@corp.test", emailVerified: true }); + }); + + /** + * ENG-2537, through the real framework rather than against the hook's return value. `return false` + * makes Better Auth resolve `createUser` to `null` and dereference it, and the resulting TypeError is + * caught and logged — which is what our own logger forwards to Sentry. Only a run against the real + * `createWithHooks` and `link-account` can tell that apart from a clean rejection, so this is the case + * that actually proves the fix: revert the throw to `return false` and the log assertion goes red. + */ + test("a gated reject redirects cleanly and reports no internal fault", async () => { + vi.mocked(gateSsoProvisioning).mockResolvedValue({ + action: "reject", + reason: SIGNUP_EMAIL_DOMAIN_BLOCKED_ERROR_CODE, + }); + + const { db, callback } = await signUpThroughGithub(true); + + // Nothing was created: the throw aborts Better Auth's user+account transaction. + expect(db.user).toHaveLength(0); + expect(db.account).toHaveLength(0); + // The discriminating assertion. Returning `false` makes Better Auth fail the creation generically — + // it dereferences null, catches its own TypeError, and redirects with `error=unable_to_create_user`, + // logging the fault our own logger forwards to Sentry. An APIError carrying a `code` is recognised + // instead, so the redirect names OUR reason and nothing is reported as a fault. + const location = callback.headers.get("location") ?? ""; + expect(location).toContain(`error=${SIGNUP_EMAIL_DOMAIN_BLOCKED_ERROR_CODE}`); + expect(location).not.toContain("unable_to_create_user"); + }); +}); diff --git a/apps/web/modules/ee/sso/lib/better-auth-hooks.ts b/apps/web/modules/ee/sso/lib/better-auth-hooks.ts index 6e350d61b795..82943dea9e2b 100644 --- a/apps/web/modules/ee/sso/lib/better-auth-hooks.ts +++ b/apps/web/modules/ee/sso/lib/better-auth-hooks.ts @@ -3,18 +3,20 @@ import type { BetterAuthOptions } from "better-auth"; import { APIError, createAuthMiddleware, getOAuthState } from "better-auth/api"; import { cookies } from "next/headers"; import { prisma } from "@formbricks/database"; +import { logger } from "@formbricks/logger"; import { SIGNUP_EMAIL_DOMAIN_BLOCKED_ERROR_CODE } from "@formbricks/types/errors"; import { normalizeUserName } from "@formbricks/types/user"; import { WEBAPP_URL } from "@/lib/constants"; import { identifyPostHogPerson } from "@/lib/posthog"; import { findMatchingLocale } from "@/lib/utils/locale"; import { getAttributionPropertiesFromCookies } from "@/modules/auth/lib/attribution"; -import { isSignupEmailDomainBlocked } from "@/modules/auth/lib/signup-email-domain"; -import { isUninvitedSignupAllowed, signupDisabledError } from "@/modules/auth/lib/signup-policy"; -import { isSignupDomainAllowed } from "@/modules/auth/lib/signup-request-context"; +import { enforceCredentialSignupBackstop } from "@/modules/auth/lib/credential-signup-backstop"; +import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler"; +import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log"; import { getIsSamlSsoEnabled, getIsSsoEnabled } from "@/modules/ee/license-check/lib/utils"; import { LINKED_SSO_LOOKUP_SELECT } from "./account-linking"; -import { normalizeSsoProvider } from "./provider-normalization"; +import { resolveSsoEmailVerifiedForCreate } from "./email-verification-policy"; +import { type TSsoIdentityProvider, normalizeSsoProvider } from "./provider-normalization"; import { gateSsoProvisioning, provisionSsoUserMemberships } from "./sso-provisioning"; import { startSsoRecovery } from "./sso-recovery"; import { @@ -48,6 +50,85 @@ export const getSsoProviderFromContext = ( return match ? match[1] : null; }; +/** + * The SSO provider this Better Auth endpoint context belongs to, normalized to the Prisma enum, or + * null when the context is not an SSO callback at all (e.g. `/sign-up/email`). Every hook below opens + * with this pair of calls, so it lives here once. + */ +const resolveSsoIdentityProvider = ( + context: Parameters[0] +): TSsoIdentityProvider | null => { + const provider = getSsoProviderFromContext(context); + return provider ? normalizeSsoProvider(provider) : null; +}; + +/** + * Record that an SSO sign-up produced an account whose address the IdP did not vouch for (ENG-2589). + * + * Dual emission, matching how SSO recovery reports itself: the audit event is the security trail, and + * the log line is what a self-hoster actually sees, since audit logging is an enterprise feature and + * `AUDIT_LOG_ENABLED` is off by default. Neither is a duplicate of the other. + * + * `warn`, not `info`: production runs at `warn`, and an unverified account is precisely the state + * worth noticing — the squatting scenario this fix exists for shows up here, one line per occurrence, + * as does an IdP that has quietly started denying every address. The fields are stable and low + * cardinality (the provider comes from a closed five-value enum, so unlike the callback-outcome logger + * there is nothing to allow-list), and none of them is redacted away: the address itself is + * deliberately absent, `userId` carries the identity for anyone who needs to follow it up. + * + * The verified case is deliberately silent — it is the overwhelming majority and already leaves a + * `user_signed_up` trail — and the whole thing is best-effort: observability must never be the reason + * a sign-up fails, so a broken logger or audit sink is swallowed rather than propagated into Better + * Auth's post-commit hook. + */ +const recordUnverifiedSsoSignup = ({ + userId, + organizationId, + provider, +}: { + userId: string; + /** Null when the sign-up provisioned no organization; recorded as UNKNOWN_DATA, per the audit schema. */ + organizationId: string | null; + provider: TSsoIdentityProvider; +}): void => { + try { + logger + // `userId` is on the log line, not only the audit event: the log is the channel a self-hoster + // actually has, and "some account was created unverified" with no way to find which one is not + // an actionable signal. The address itself stays out — the id is enough to look it up. + .withContext({ source: "sso-signup", ssoProvider: provider, userId, emailVerified: false }) + .warn("SSO sign-up created an account the identity provider did not report as verified"); + + // Not awaited: the hook is on the sign-in path, and the helper does its work inside `setImmediate`, + // so there is nothing to wait for. The rejection handler below is defence in depth rather than a + // live hazard — today that helper cannot reject, because its body is entirely inside the + // `setImmediate` callback — but an unawaited promise that ever did would take the process down + // rather than fail this one sign-up, and this runs post-commit on the sign-in path. + void queueAuditEventBackground({ + // `updated` + a marker key, not `created`: this records a PROPERTY of a user creation, and it is + // emitted only for the unverified subset. An `action: "created"` / `targetType: "user"` pair + // exists nowhere else, so a consumer counting account creations off it would silently see only + // the accounts an IdP declined to vouch for. The marker idiom is the house convention for + // auth-internal events (`passwordResetMarker`, `verificationSessionWithheldMarker`). + action: "updated", + targetType: "user", + userId, + userType: "user", + targetId: userId, + organizationId: organizationId ?? UNKNOWN_DATA, + status: "success", + // A marker key rather than a new `ZAuditAction` value: this is a property of the user creation + // already being recorded, not a flow of its own, and it keeps the shared enum untouched. Every + // key here survives `redactPII` — an `email` would not, which is the other reason it is absent. + newObject: { ssoUnverifiedSignupMarker: true, provider, emailVerified: false }, + }).catch((error: unknown) => { + logger.warn({ error }, "Failed to audit an unverified SSO sign-up"); + }); + } catch (error) { + logger.warn({ error }, "Failed to record an unverified SSO sign-up"); + } +}; + /** * Fallback display name when the IdP supplies no name: humanize the email local-part (treat `. _ +` as * word separators) and run it through the shared normalizer. The name allowlist lives only in @@ -59,10 +140,12 @@ const deriveNameFromEmail = (email: string): string => /** * Better Auth `databaseHooks` re-expressing Formbricks' SSO sign-up flow (design doc §13), reusing * the existing logic via `./sso-provisioning`: - * - `user.create.before` — gate the SSO sign-up (`gateSsoProvisioning`; a reject returns `false`, - * which rolls back inside Better Auth's user+account transaction, so no orphan user is created); - * stash the resolved decision for the after-hook; and enrich the insert (email-verified — the IdP - * attests it; `identityProvider`; request-matched `locale`; email-localpart name fallback). + * - `user.create.before` — gate the SSO sign-up (`gateSsoProvisioning`; a reject THROWS an APIError + * carrying the reason code, which aborts Better Auth's user+account transaction, so no orphan user + * is created — see the reject below for why `return false` is wrong here); + * stash the resolved decision for the after-hook; and enrich the insert (email-verified — the IdP's + * own claim, trusted per `./email-verification-policy`; `identityProvider`; request-matched + * `locale`; email-localpart name fallback). * - `user.create.after` — run the membership/team/notification provisioning (`provisionSsoUserMemberships`). * - `account.create.after` — denormalize `identityProvider` + `identityProviderAccountId` onto * `User` for legacy SSO lookups (`findLegacyExactMatch`), parity with `syncSsoIdentityForUser`. @@ -76,36 +159,12 @@ export const ssoDatabaseHooks: NonNullable = user: { create: { before: async (user, context) => { - const provider = getSsoProviderFromContext(context); - const identityProvider = provider ? normalizeSsoProvider(provider) : null; - if (!identityProvider) { - // Credential sign-up. createUserAction runs the full personal-email policy (Cloud gate + - // invite exemption) and marks the request scope before calling signUpEmail. If that mark is - // absent, this is a direct POST to Better Auth's native /sign-up/email — which bypasses the - // action — so re-enforce the domain block here (no invite is carried on that raw path). - // One read, two guards: both re-checks below exist only for a request that skipped the action. - const wentThroughAction = isSignupDomainAllowed(); - if (!wentThroughAction && (await isSignupEmailDomainBlocked(user.email, async () => false))) { - return false; - } - // ENG-2293 BACKSTOP: closed-instance policy (SIGNUP_ENABLED / multi-org / fresh-instance). - // The primary gate is `signupPolicyBeforeHandler` in auth.ts's `hooks.before`, which rejects - // `POST /sign-up/email` before Better Auth looks the address up — deliberately NOT here, - // because this hook only ever runs for an address that does not yet exist (the duplicate - // branch returns a synthetic 200 without creating anything), so rejecting here and nowhere - // else would answer "does this address have an account?". See signup-policy.ts. - // - // Kept anyway because this hook covers EVERY credential user-creation path, not just the one - // route the before-hook names: any future Better Auth plugin that creates a user (magic link, - // email OTP, admin create) lands here, and on a closed instance it should not. - if (!wentThroughAction && !(await isUninvitedSignupAllowed())) { - throw signupDisabledError(); - } - return; // otherwise keep credential-signup defaults - } + const identityProvider = resolveSsoIdentityProvider(context); + // Not an SSO callback → a credential user creation, whose policy is a separate concern. + if (!identityProvider) return enforceCredentialSignupBackstop(user.email); - // Provisioning gate — orphan-safe: a reject returns `false`, rolling back the user+account - // insert inside Better Auth's transaction (a post-commit after-hook could not reject safely). + // Provisioning gate — orphan-safe: a reject aborts Better Auth's user+account transaction, so + // no half-created user survives (a post-commit after-hook could not reject safely). let callbackUrl = ""; try { callbackUrl = (await getOAuthState())?.callbackURL ?? ""; @@ -116,9 +175,27 @@ export const ssoDatabaseHooks: NonNullable = if (decision.action === "reject") { // Stash the reason (request scope) so the after-hook can turn Better Auth's generic // create-failure redirect into a tailored one — e.g. the personal-email block redirects to - // /auth/signup with a toast. Returning false rolls back the user+account insert. + // /auth/signup with a toast. setSsoSignupRejectReason(decision.reason); - return false; + // THROW, do not `return false` (ENG-2537). Returning false is the documented way to reject, + // but Better Auth then has `createUser` resolve to `null` (`db/with-hooks.mjs`) and reads + // `createdUser.id` off it on the next line of `oauth2/link-account.mjs`. That TypeError is + // caught there and turned into a generic `unable_to_create_user` redirect — so not a 500 — + // but the catch logs it, and `betterAuthLogger` forwards a caught non-APIError to Sentry. + // Every rejected SSO sign-up therefore reported an internal fault for what is an ordinary + // policy decision (FORMBRICKS-19M). + // + // An APIError carrying a `code` is the shape the OAuth callback handles deliberately: it + // catches one and turns it into a redirect naming that code (`api/routes/callback.mjs`), + // which is what lets the after-hook below rewrite the destination. + // + // Nothing is orphaned either way — `with-hooks.mjs` returns null BEFORE the insert, so the + // rolled-back transaction was always empty — but the throw makes that explicit rather than + // incidental. + throw new APIError("FORBIDDEN", { + message: "SSO sign-up was rejected by the instance's provisioning policy.", + code: decision.reason, + }); } setSsoProvisioningDecision(decision); // carried to user.create.after (the membership writes) @@ -135,7 +212,11 @@ export const ssoDatabaseHooks: NonNullable = // provider, request-matched locale, and an email-localpart name when the IdP gave none. return { data: { - emailVerified: true, + // The IdP's own answer reaches us as `user.emailVerified` — computed by Better Auth for + // google/github, by our own `mapProfileToUser` for the generic providers — and whatever we + // return here is shallow-merged over it. How far each provider's claim is trusted lives in + // one table, `./email-verification-policy` (ENG-2589). + emailVerified: resolveSsoEmailVerifiedForCreate(identityProvider, user.emailVerified), identityProvider, locale: await findMatchingLocale(), // `User` has no `image` column (parity with provisionNewSsoUser, which never stored it). @@ -156,9 +237,18 @@ export const ssoDatabaseHooks: NonNullable = after: async (user, context) => { const decision = getSsoProvisioningDecision(); if (!decision) return; // not a gated SSO sign-up - const provider = getSsoProviderFromContext(context); - const identityProvider = provider ? normalizeSsoProvider(provider) : null; + const identityProvider = resolveSsoIdentityProvider(context); if (!identityProvider) return; + + // Before the provisioning writes, so a failure in those cannot swallow the security signal. + if (user.emailVerified === false) { + recordUnverifiedSsoSignup({ + userId: user.id, + organizationId: decision.organizationId, + provider: identityProvider, + }); + } + await provisionSsoUserMemberships({ userId: user.id, email: user.email, @@ -206,8 +296,7 @@ export const ssoDatabaseHooks: NonNullable = * (matching NextAuth's behavior) is a Phase 7 refinement. */ export const ssoLicenseGateBeforeHandler = async (ctx: AuthHookContext): Promise => { - const provider = getSsoProviderFromContext(ctx); - const identityProvider = provider ? normalizeSsoProvider(provider) : null; + const identityProvider = resolveSsoIdentityProvider(ctx); if (!identityProvider) return; // not an SSO callback → no license gate if (!(await getIsSsoEnabled())) { @@ -240,8 +329,7 @@ export type AuthHookContext = Parameters * middleware (which would re-run `createInternalContext` + the middleware `use` chain). */ export const ssoRecoveryAfterHandler = async (ctx: AuthHookContext): Promise => { - const providerId = getSsoProviderFromContext(ctx); - const provider = providerId ? normalizeSsoProvider(providerId) : null; + const provider = resolveSsoIdentityProvider(ctx); if (!provider) return; // not an SSO callback // Only act on Better Auth's account-collision redirect (handleOAuthUserInfo returns @@ -283,8 +371,8 @@ export const ssoRecoveryAfter = createAuthMiddleware(ssoRecoveryAfterHandler); /** * Request hook (`hooks.after`) that redirects a personal-email SSO sign-up rejection back to the - * sign-up page. When `user.create.before` rejects the domain (returns `false`, rolling back the - * insert), Better Auth finishes the OAuth callback with a generic error redirect (to /auth/login). + * sign-up page. When `user.create.before` rejects the domain (throws, aborting the insert), Better + * Auth finishes the OAuth callback with an error redirect carrying that reason code. * We detect our own reject reason — stashed in the same request scope — and rewrite that redirect to * `/auth/signup?error=`; the sign-up form reads the param and toasts the localized message. * diff --git a/apps/web/modules/ee/sso/lib/better-auth-oidc-email-verified.test.ts b/apps/web/modules/ee/sso/lib/better-auth-oidc-email-verified.test.ts new file mode 100644 index 000000000000..af8883ae8dfd --- /dev/null +++ b/apps/web/modules/ee/sso/lib/better-auth-oidc-email-verified.test.ts @@ -0,0 +1,201 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +/** + * ENG-2589 — the generic-OIDC half, proven end to end against a REAL Better Auth instance. + * + * The unit tests around `mapProfileToUser` show the mapper returns the right value; they cannot show + * that the value SURVIVES. Better Auth resolves a generic-OAuth profile through + * `emailVerified: userInfo.data.email_verified ?? false` before any database hook runs, and only the + * mapper's return spreading last keeps our answer authoritative — an upstream reorder, or a release + * that stops calling the mapper on this path, would silently restore the coalesced value and hand a + * verified account to an IdP-unverified address. That is the regression this file exists to catch. + * + * The flow is driven with **no `id_token` in the token response**, deliberately: that is what forces + * the userinfo branch, which is the one carrying the `?? false`. The discovery document omits + * `jwks_uri` for the same reason it is omitted in the SSO smoke recipe — with one present, Better Auth + * builds an id_token verification config and the flow would need a signed token to get anywhere. + */ +const BASE_URL = "https://app.formbricks.test"; +const IDP = "https://idp.formbricks.test"; + +const { captureSsoIdentity } = vi.hoisted(() => ({ captureSsoIdentity: vi.fn() })); +const { runWithSsoRequestContext } = await import("./sso-request-context"); +vi.mock("./sso-request-context", async () => { + const actual = await vi.importActual("./sso-request-context"); + return { ...actual, captureSsoIdentity }; +}); + +// The SSO gate and the membership writes are exercised by their own suites; here they must simply +// allow the sign-up so the row reaches the database, which is what this file asserts on. +vi.mock("./sso-provisioning", () => ({ + gateSsoProvisioning: vi.fn(async () => ({ + action: "provision", + organizationId: "org-1", + assignToDefaultTeam: false, + signupSource: "direct", + })), + provisionSsoUserMemberships: vi.fn(), +})); +vi.mock("./sso-recovery", () => ({ startSsoRecovery: vi.fn() })); +vi.mock("@formbricks/database", () => ({ prisma: { user: { findUnique: vi.fn() } } })); +vi.mock("@/lib/posthog", () => ({ identifyPostHogPerson: vi.fn() })); +vi.mock("@/lib/utils/locale", () => ({ findMatchingLocale: vi.fn(async () => "en-US") })); +// Async like the real helper: the sign-up path calls it without awaiting, so a stub returning +// `undefined` would put a shape through this flow that production never sees. +vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({ + queueAuditEventBackground: vi.fn(async () => undefined), +})); +vi.mock("next/headers", () => ({ cookies: vi.fn(async () => new Map()) })); + +vi.mock("@/lib/env", async () => { + const actual = await vi.importActual<{ env: Record }>("@/lib/env"); + return { env: { ...actual.env, BETTER_AUTH_URL: BASE_URL } }; +}); + +/** The openid provider is built at import time from the constants, so they are mocked before import. */ +const loadOidcProvider = async () => { + vi.resetModules(); + vi.doMock("@/lib/constants", async () => { + const actual = await vi.importActual>("@/lib/constants"); + return { + ...actual, + ENTERPRISE_LICENSE_KEY: "lic", + WEBAPP_URL: BASE_URL, + GITHUB_OAUTH_ENABLED: false, + GOOGLE_OAUTH_ENABLED: false, + AZURE_OAUTH_ENABLED: false, + SAML_OAUTH_ENABLED: false, + OIDC_OAUTH_ENABLED: true, + OIDC_CLIENT_ID: "oidc-id", + OIDC_CLIENT_SECRET: "oidc-secret", + OIDC_ISSUER: IDP, + }; + }); + const providers = await import("./better-auth-providers"); + const hooks = await import("./better-auth-hooks"); + return { ssoGenericOAuthConfig: providers.ssoGenericOAuthConfig, ssoDatabaseHooks: hooks.ssoDatabaseHooks }; +}; + +/** + * Every outbound call the flow makes, stubbed at the IdP boundary. `emailVerified` is what the IdP + * asserts on its userinfo response — the value under test; `undefined` omits the claim entirely. + */ +const stubIdp = (emailVerified: boolean | undefined) => { + const fetchMock = vi.fn(async (input: string | URL | Request) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + + if (url.startsWith(`${IDP}/.well-known/openid-configuration`)) { + // No `jwks_uri`: keeps Better Auth off the id_token verification path (see the file docblock). + return Response.json({ + issuer: IDP, + authorization_endpoint: `${IDP}/authorize`, + token_endpoint: `${IDP}/token`, + userinfo_endpoint: `${IDP}/userinfo`, + }); + } + if (url.startsWith(`${IDP}/token`)) { + // No `id_token`, so identity must come from userinfo — the branch that coalesces the claim. + return Response.json({ access_token: "oidc-access-token", token_type: "Bearer", expires_in: 3600 }); + } + if (url.startsWith(`${IDP}/userinfo`)) { + return Response.json({ + sub: "oidc-subject", + email: "squatter@corp.test", + name: "Squatter", + ...(emailVerified === undefined ? {} : { email_verified: emailVerified }), + }); + } + throw new Error(`unexpected outbound fetch: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +}; + +const signUpThroughOidc = async (emailVerified: boolean | undefined) => { + stubIdp(emailVerified); + const { ssoGenericOAuthConfig, ssoDatabaseHooks } = await loadOidcProvider(); + const { betterAuth } = await import("better-auth"); + const { memoryAdapter } = await import("better-auth/adapters/memory"); + const { genericOAuth } = await import("better-auth/plugins"); + + const db: Record[]> = { + user: [], + session: [], + account: [], + verification: [], + }; + const auth = betterAuth({ + baseURL: BASE_URL, + secret: "eng-2589-oidc-email-verified-boundary-secret", + database: memoryAdapter(db), + user: { + additionalFields: { + identityProvider: { type: "string", required: false, input: false }, + identityProviderAccountId: { type: "string", required: false, input: false }, + }, + }, + plugins: [genericOAuth({ config: ssoGenericOAuthConfig })], + databaseHooks: ssoDatabaseHooks, + }); + + // Leg 1 — the authorization request, for the real state row and its signed cookie. + const signIn = await auth.handler( + new Request(`${BASE_URL}/api/auth/sign-in/social`, { + method: "POST", + headers: { "content-type": "application/json", origin: BASE_URL }, + body: JSON.stringify({ provider: "openid", callbackURL: "/" }), + }) + ); + expect(signIn.status).toBe(200); + const { url } = (await signIn.json()) as { url: string }; + const state = new URL(url).searchParams.get("state") ?? ""; + expect(state).not.toBe(""); + const cookie = (signIn.headers.getSetCookie?.() ?? []).map((v) => v.split(";")[0]).join("; "); + + // Leg 2 — the callback, which runs the mapper, the coalesce, and our database hook in the real order. + await runWithSsoRequestContext(() => + auth.handler( + new Request(`${BASE_URL}/api/auth/callback/openid?code=oidc-code&state=${state}`, { + headers: { cookie }, + }) + ) + ); + + // Anchor the flow: a rejected callback leaves `db.user` empty, and a bare assertion on `undefined` + // would blame the claim for what was really a broken stub. + expect(db.user).toHaveLength(1); + return db.user[0]; +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.doUnmock("@/lib/constants"); +}); + +describe("generic OIDC sign-up persists the IdP's email_verified claim (real Better Auth, ENG-2589)", () => { + test("an IdP that asserts the address is unverified must not mint a verified account", async () => { + const user = await signUpThroughOidc(false); + expect(user).toMatchObject({ email: "squatter@corp.test", emailVerified: false }); + }); + + test("an IdP that attests the address keeps it verified", async () => { + const user = await signUpThroughOidc(true); + expect(user).toMatchObject({ email: "squatter@corp.test", emailVerified: true }); + }); + + /** + * The upgrade-safety guarantee, and the reason the fix is not simply "honour the value Better Auth + * gives us". An IdP that never sends the claim must keep producing verified users, or every + * self-hoster whose provider omits it gets a fleet of unverified accounts on upgrade. Upstream's + * `?? false` makes this case indistinguishable from the asserted-false one above — that these two + * tests disagree is the whole point of reading the raw claim. + */ + test("an IdP that never sends the claim still produces a verified account", async () => { + const user = await signUpThroughOidc(undefined); + expect(user).toMatchObject({ email: "squatter@corp.test", emailVerified: true }); + }); +}); diff --git a/apps/web/modules/ee/sso/lib/better-auth-providers.test.ts b/apps/web/modules/ee/sso/lib/better-auth-providers.test.ts index e63cb70fb280..29e8bc4535aa 100644 --- a/apps/web/modules/ee/sso/lib/better-auth-providers.test.ts +++ b/apps/web/modules/ee/sso/lib/better-auth-providers.test.ts @@ -450,9 +450,12 @@ describe("better-auth SSO providers", () => { const azure = m.ssoGenericOAuthConfig.find((c) => c.providerId === "azuread"); const mapper = azure?.mapProfileToUser; + // `emailVerified: true` on every profile here: none of them carries an `email_verified` claim, + // and an absent claim resolves to verified (ENG-2589). The claim matrix itself is below. expect(callMapper(mapper, { email: "a@az.test", sub: "az-sub", name: "Ada Lovelace" })).toEqual({ email: "a@az.test", name: "Ada Lovelace", + emailVerified: true, }); expect(captureSsoIdentity).toHaveBeenLastCalledWith({ email: "a@az.test", @@ -461,11 +464,12 @@ describe("better-auth SSO providers", () => { expect( callMapper(mapper, { email: "b@az.test", sub: "s", given_name: "Grace", family_name: "Hopper" }) - ).toEqual({ email: "b@az.test", name: "Grace Hopper" }); + ).toEqual({ email: "b@az.test", name: "Grace Hopper", emailVerified: true }); expect(callMapper(mapper, { email: "c@az.test", sub: "s", preferred_username: "charles" })).toEqual({ email: "c@az.test", name: "charles", + emailVerified: true, }); }); @@ -492,6 +496,7 @@ describe("better-auth SSO providers", () => { ).toEqual({ email: "d@idp.test", name: "Dee", + emailVerified: true, // no claim on this profile → verified (ENG-2589) }); expect(captureSsoIdentity).toHaveBeenLastCalledWith({ email: "d@idp.test", @@ -573,6 +578,118 @@ describe("better-auth SSO providers", () => { expect(loggerWarn).not.toHaveBeenCalled(); }); + /** + * ENG-2589. The generic providers read the RAW `email_verified` claim in their mapper, because + * that is the only place the three-way answer still exists — Better Auth's own value has already + * been through `email_verified ?? false` by the time any hook sees it, which cannot tell an IdP + * that asserted `false` from one that never sent the claim. + * + * The absent case is the load-bearing one: it must stay `true`, or every self-hoster whose IdP + * omits the claim gets unverified users on upgrade. + */ + describe("generic providers resolve the raw email_verified claim (ENG-2589)", () => { + const genericMapper = async (providerId: "azuread" | "openid") => { + const m = await loadProviders({ + ENTERPRISE_LICENSE_KEY: "lic", + AZURE_OAUTH_ENABLED: true, + OIDC_OAUTH_ENABLED: true, + OIDC_CLIENT_ID: "oidc-id", + OIDC_CLIENT_SECRET: "oidc-secret", + OIDC_ISSUER: "https://idp.test", + }); + const config = m.ssoGenericOAuthConfig.find((c) => c.providerId === providerId); + if (!config) throw new Error(`${providerId} provider not registered`); + return config.mapProfileToUser; + }; + + test.each([ + { claim: { email_verified: true }, expected: true, label: "asserted true" }, + { claim: { email_verified: false }, expected: false, label: "asserted false" }, + { claim: { email_verified: "false" }, expected: false, label: "asserted false as a string" }, + { claim: { email_verified: "true" }, expected: true, label: "asserted true as a string" }, + { claim: {}, expected: true, label: "absent — the upgrade-safe default" }, + { claim: { email_verified: null }, expected: true, label: "null" }, + ])("azuread: $label → $expected", async ({ claim, expected }) => { + const mapper = await genericMapper("azuread"); + expect(callMapper(mapper, { email: "a@az.test", sub: "s", ...claim })).toMatchObject({ + emailVerified: expected, + }); + }); + + test.each([ + { claim: { email_verified: true }, expected: true, label: "asserted true" }, + { claim: { email_verified: false }, expected: false, label: "asserted false" }, + { claim: { email_verified: "false" }, expected: false, label: "asserted false as a string" }, + { claim: {}, expected: true, label: "absent — the upgrade-safe default" }, + ])("openid: $label → $expected", async ({ claim, expected }) => { + const mapper = await genericMapper("openid"); + expect(callMapper(mapper, { email: "d@idp.test", sub: "s", ...claim })).toMatchObject({ + emailVerified: expected, + }); + }); + + /** + * The azuread claim reaches the mapper only because `microsoftGraphUserInfo` spreads the raw Graph + * profile. That spread is now load-bearing and nothing else asserts it: an edit that normalised or + * whitelisted the Graph response would strip the claim, every Azure sign-up would silently resolve + * verified, and every other test here would still pass because they feed the mapper directly. + */ + test("the Graph userinfo passes the raw verification claims through to the mapper", async () => { + const m = await loadProviders({ + ENTERPRISE_LICENSE_KEY: "lic", + AZURE_OAUTH_ENABLED: true, + AZUREAD_TENANT_ID: "common", // the explicit-endpoint branch, where getUserInfo is ours + }); + const azure = m.ssoGenericOAuthConfig.find((c) => c.providerId === "azuread"); + if (!azure?.getUserInfo) throw new Error("azuread should use the Graph override on `common`"); + + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + json: async () => ({ + sub: "graph-sub", + email: "user@corp.test", + email_verified: false, + xms_edov: false, + }), + })) + ); + + const profile = (await azure.getUserInfo({ accessToken: "t" } as never)) as Record; + + expect(profile).toMatchObject({ email_verified: false, xms_edov: false }); + // And the mapper turns them into a denial, which is the property that actually matters. + expect(callMapper(azure.mapProfileToUser, profile)).toMatchObject({ emailVerified: false }); + }); + + // Entra emits no `email_verified` at all; `xms_edov` is Microsoft's own equivalent, and honouring + // it is what makes azuread's `raw-claim` classification true rather than aspirational. + test("azuread honours xms_edov when email_verified is absent", async () => { + const m = await loadProviders({ ENTERPRISE_LICENSE_KEY: "lic", AZURE_OAUTH_ENABLED: true }); + const azure = m.ssoGenericOAuthConfig.find((c) => c.providerId === "azuread"); + + expect( + callMapper(azure?.mapProfileToUser, { email: "a@az.test", sub: "s", xms_edov: false }) + ).toMatchObject({ emailVerified: false }); + // A tenant that enables neither claim is unchanged — absent stays verified. + expect(callMapper(azure?.mapProfileToUser, { email: "a@az.test", sub: "s" })).toMatchObject({ + emailVerified: true, + }); + }); + + // SAML is `never-attests`: it must not smuggle a claim into the mapped user at all, so the hook's + // forced `true` is the single decision for it (BoxyHQ carries no `email_verified` on any path). + test("saml maps no emailVerified at all", async () => { + const m = await loadProviders({ ENTERPRISE_LICENSE_KEY: "lic", SAML_OAUTH_ENABLED: true }); + const saml = m.ssoGenericOAuthConfig.find((c) => c.providerId === "saml"); + if (!saml) throw new Error("saml provider not registered"); + + const mapped = callMapper(saml.mapProfileToUser, { email: "s@saml.test", id: "saml-id" }); + expect(mapped).not.toHaveProperty("emailVerified"); + }); + }); + test("SAML bridges to the local Jackson endpoints and resolves first/last name", async () => { const m = await loadProviders({ ENTERPRISE_LICENSE_KEY: "lic", diff --git a/apps/web/modules/ee/sso/lib/better-auth-providers.ts b/apps/web/modules/ee/sso/lib/better-auth-providers.ts index 690375f5990c..8d887684542c 100644 --- a/apps/web/modules/ee/sso/lib/better-auth-providers.ts +++ b/apps/web/modules/ee/sso/lib/better-auth-providers.ts @@ -25,6 +25,7 @@ import { } from "@/lib/constants"; import { getAuthIssuerUrl } from "@/modules/auth/lib/oauth-urls"; import { ssoAccountIssuer } from "./constants"; +import { resolveEmailVerifiedFromRawClaim } from "./email-verification-policy"; import { captureSsoIdentity } from "./sso-request-context"; // Better Auth's per-provider profile types, extracted so the social mappers below aren't implicitly @@ -258,9 +259,10 @@ const microsoftGraphUserInfo = async (tokens: { if (!profile?.sub) return null; return { ...profile, - // Strictly `=== true`, not Better Auth's `?? false`: this flag is a claim from the provider that - // feeds provisioning, so anything that is not an explicit boolean true is treated as unverified. - emailVerified: profile.email_verified === true, + // `emailVerified` is deliberately NOT set here. The raw `email_verified` claim rides along in the + // spread above, and this provider's `mapProfileToUser` resolves it through the shared policy + // (ENG-2589) — a mapper's return spreads last over whatever this function produced, so anything + // decided here would be overwritten. One decision, in one place, rather than two that must agree. image: typeof profile.picture === "string" ? profile.picture : undefined, }; } catch { @@ -366,6 +368,22 @@ const oidcEndpoints = oidcTemplateIssuerAuthority ? microsoftExplicitEndpoints(oidcTemplateIssuerAuthority) : { discoveryUrl: `${OIDC_ISSUER}/.well-known/openid-configuration` }; +/** + * Why `email_verified` is read in these mappers rather than in the sign-up hook (ENG-2589). + * + * Better Auth resolves the generic-OAuth profile down to a user object before any database hook runs, + * and on the userinfo path it coalesces the claim: `emailVerified: userInfo.data.email_verified ?? false` + * (`better-auth/dist/plugins/generic-oauth/index.mjs`). That single `??` destroys the distinction the + * whole fix turns on — an IdP that ASSERTS `false` and one that simply never sends the claim both + * arrive as `false`, and treating the second as a denial would flip every new user to unverified on + * upgrade for self-hosters whose IdP omits it. + * + * `mapProfileToUser` is upstream of that coalesce and receives the raw profile intact (the whole + * `...decoded` / `...userInfo.data` spread), so it is the only place the three-way answer still exists. + * Its return value spreads LAST over Better Auth's own fields, so an `emailVerified` returned here is + * authoritative all the way into `createUser` — and into the sign-in upgrade path, which flips an + * existing unverified row to verified when the IdP later attests the address. + */ export const ssoGenericOAuthConfig: GenericOAuthConfig[] = ENTERPRISE_LICENSE_KEY ? [ ...(AZURE_OAUTH_ENABLED @@ -397,6 +415,16 @@ export const ssoGenericOAuthConfig: GenericOAuthConfig[] = ENTERPRISE_LICENSE_KE return { email: profile.email, name: toDisplayName(profile), + // Read the RAW claim here, which is the only place it survives intact (ENG-2589). + // + // Entra is the one provider that does not speak `email_verified` at all: neither its + // id_tokens nor Graph's `/oidc/userinfo` carry it. Microsoft's equivalent is the + // OPTIONAL `xms_edov` ("email domain owner verified"), which a tenant has to enable on + // the app registration — and which is exactly the signal that says whether the `email` + // claim is a proven address or just a mutable directory attribute. Falling back to it + // means a tenant that opts in gets its denial honoured; one that does not is unchanged, + // because an absent claim resolves the same way either way. + emailVerified: resolveEmailVerifiedFromRawClaim(profile.email_verified ?? profile.xms_edov), }; }, } satisfies GenericOAuthConfig, @@ -427,6 +455,8 @@ export const ssoGenericOAuthConfig: GenericOAuthConfig[] = ENTERPRISE_LICENSE_KE email: profile.email, // Parity with provisionNewSsoUser (OIDC): name → given+family → preferred_username. name: toDisplayName(profile), + // Read the RAW claim here, which is the only place it survives intact (ENG-2589). + emailVerified: resolveEmailVerifiedFromRawClaim(profile.email_verified), }; }, } satisfies GenericOAuthConfig, @@ -457,6 +487,10 @@ export const ssoGenericOAuthConfig: GenericOAuthConfig[] = ENTERPRISE_LICENSE_KE email: profile.email, // Parity with provisionNewSsoUser (SAML): name → firstName + lastName. name: toSamlDisplayName(profile), + // No `emailVerified`, deliberately: SAML is a permanent `never-attests` provider in + // ./email-verification-policy. Jackson's userinfo shape carries no `email_verified`, + // and this provider requests no `openid` scope, so no id_token one could ride in on + // is ever minted — there is nothing to read, and the hook verifies the row. }; }, } satisfies GenericOAuthConfig, diff --git a/apps/web/modules/ee/sso/lib/email-verification-policy.test.ts b/apps/web/modules/ee/sso/lib/email-verification-policy.test.ts new file mode 100644 index 000000000000..c4f43a7f0650 --- /dev/null +++ b/apps/web/modules/ee/sso/lib/email-verification-policy.test.ts @@ -0,0 +1,141 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { + SSO_EMAIL_VERIFICATION_TRUST, + resolveEmailVerifiedFromRawClaim, + resolveSsoEmailVerifiedForCreate, +} from "./email-verification-policy"; + +// The mappers capture the SSO identity into request-scoped storage; stub it so they run standalone. +vi.mock("./sso-request-context", () => ({ captureSsoIdentity: vi.fn() })); + +/** + * ENG-2589. The trust decision behind `User.emailVerified` on SSO sign-up, isolated from Better Auth + * so the whole matrix is cheap to state: minting `true` for an address the IdP itself calls unproven + * hands a squatter a verified account on someone else's email, while denying an address the IdP simply + * never spoke about would break every self-hosted instance whose IdP omits the claim. + */ +describe("resolveEmailVerifiedFromRawClaim", () => { + test.each([ + { claim: false, expected: false, label: "the IdP asserted the address is NOT verified" }, + { claim: "false", expected: false, label: "the same assertion serialized as a string" }, + // A denial arrives in whatever spelling the provider's backend produces, and every one of these + // used to fail OPEN — the same bug this module exists to close, one serialization removed. + { claim: "False", expected: false, label: "a Python-style capitalized string" }, + { claim: "FALSE", expected: false, label: "an upper-cased string" }, + { claim: " false ", expected: false, label: "a padded string" }, + { claim: 0, expected: false, label: "the numeric false some IdPs send" }, + { claim: "0", expected: false, label: "that numeric false as a string" }, + { claim: true, expected: true, label: "the IdP attested the address" }, + { claim: "true", expected: true, label: "attestation serialized as a string" }, + { claim: 1, expected: true, label: "the numeric true" }, + { claim: undefined, expected: true, label: "absent — the IdP asserted nothing" }, + { claim: null, expected: true, label: "null — still not an assertion of falsity" }, + { claim: "", expected: true, label: "empty string" }, + { claim: {}, expected: true, label: "a malformed object" }, + ])("$label → $expected", ({ claim, expected }) => { + expect(resolveEmailVerifiedFromRawClaim(claim)).toBe(expected); + }); + + /** + * The asymmetry IS the design, so it gets its own assertion rather than living implicitly in the + * table above: only an explicit denial denies. Collapsing these two cases together is exactly the + * bug — `email_verified ?? false` upstream — that reading the raw claim exists to route around. + */ + test("distinguishes an asserted false from a claim that was never sent", () => { + expect(resolveEmailVerifiedFromRawClaim(false)).toBe(false); + expect(resolveEmailVerifiedFromRawClaim(undefined)).toBe(true); + }); +}); + +describe("resolveSsoEmailVerifiedForCreate", () => { + // `attested` (Better Auth computed it) and `raw-claim` (our mapper computed it) both arrive as + // `user.emailVerified`, so both honour it strictly. + test.each(["google", "github", "azuread", "openid"] as const)( + "%s honours the claim that reached the hook", + (provider) => { + expect(resolveSsoEmailVerifiedForCreate(provider, true)).toBe(true); + expect(resolveSsoEmailVerifiedForCreate(provider, false)).toBe(false); + // Strictly `=== true`: an absent value is not attestation. The raw-claim mappers have already + // turned a genuinely absent claim into `true` before this point, so nothing legitimate is lost. + expect(resolveSsoEmailVerifiedForCreate(provider, undefined)).toBe(false); + } + ); + + // SAML can carry no claim on any path, so there is nothing to honour and nothing to lose. + test.each([true, false, undefined])("saml is verified regardless of the value %s", (value) => { + expect(resolveSsoEmailVerifiedForCreate("saml", value)).toBe(true); + }); +}); + +/** + * The table only earns its keep if the providers actually behave the way it says. `resolveSsoEmailVerified + * ForCreate` branches on `never-attests` alone, and the `raw-claim` behaviour lives in each provider's + * own `mapProfileToUser` — so without a check tying the two together, an entry could read `raw-claim` + * while its mapper quietly ignored the claim, and nothing would fail. These assert the tie. + */ +describe("SSO_EMAIL_VERIFICATION_TRUST is consistent with what the providers actually do", () => { + const loadGenericMappers = async () => { + vi.resetModules(); + vi.doMock("@/lib/constants", async () => { + const actual = await vi.importActual>("@/lib/constants"); + return { + ...actual, + ENTERPRISE_LICENSE_KEY: "lic", + AZURE_OAUTH_ENABLED: true, + AZUREAD_CLIENT_ID: "az", + AZUREAD_CLIENT_SECRET: "az-secret", + AZUREAD_TENANT_ID: "00000000-1111-2222-3333-444444444444", // concrete → discovery branch + OIDC_OAUTH_ENABLED: true, + OIDC_CLIENT_ID: "oidc", + OIDC_CLIENT_SECRET: "oidc-secret", + OIDC_ISSUER: "https://idp.test", + SAML_OAUTH_ENABLED: true, + GITHUB_OAUTH_ENABLED: false, + GOOGLE_OAUTH_ENABLED: false, + }; + }); + const { ssoGenericOAuthConfig } = await import("./better-auth-providers"); + return ssoGenericOAuthConfig; + }; + + afterEach(() => { + vi.doUnmock("@/lib/constants"); + }); + + test("every raw-claim provider's mapper actually honours a denial", async () => { + const configs = await loadGenericMappers(); + const rawClaimProviders = Object.entries(SSO_EMAIL_VERIFICATION_TRUST) + .filter(([, trust]) => trust === "raw-claim") + .map(([provider]) => provider); + expect(rawClaimProviders.length).toBeGreaterThan(0); + + for (const provider of rawClaimProviders) { + const config = configs.find((c) => c.providerId === provider); + if (!config?.mapProfileToUser) throw new Error(`${provider} is raw-claim but registers no mapper`); + + const mapped = (config.mapProfileToUser as (p: Record) => { emailVerified?: boolean })( + { email: "a@b.test", sub: "s", email_verified: false } + ); + expect(mapped.emailVerified, `${provider} must honour an asserted false`).toBe(false); + } + }); + + test("no never-attests provider smuggles a claim into the mapped user", async () => { + const configs = await loadGenericMappers(); + const neverAttests = Object.entries(SSO_EMAIL_VERIFICATION_TRUST) + .filter(([, trust]) => trust === "never-attests") + .map(([provider]) => provider); + + for (const provider of neverAttests) { + const config = configs.find((c) => c.providerId === provider); + if (!config?.mapProfileToUser) continue; // not a generic provider + + const mapped = (config.mapProfileToUser as (p: Record) => Record)({ + email: "a@b.test", + id: "s", + email_verified: false, + }); + expect(mapped, `${provider} must leave the decision to the hook`).not.toHaveProperty("emailVerified"); + } + }); +}); diff --git a/apps/web/modules/ee/sso/lib/email-verification-policy.ts b/apps/web/modules/ee/sso/lib/email-verification-policy.ts new file mode 100644 index 000000000000..f63dd42b7d44 --- /dev/null +++ b/apps/web/modules/ee/sso/lib/email-verification-policy.ts @@ -0,0 +1,104 @@ +import type { TSsoIdentityProvider } from "./provider-normalization"; + +/** + * How much an SSO provider's `email_verified` signal can be trusted (ENG-2589). + * + * `emailVerified` is what the app treats as proof the account holder controls the address, so minting + * it for an address the identity provider itself calls unproven hands a squatter a verified account on + * someone else's email. But the providers do not all carry the same signal, and Better Auth flattens + * that difference away before our hooks see it — hence a per-provider policy rather than one rule. + * + * - `attested` — Better Auth derives a real assertion from the provider and hands it to + * `user.create.before` as `user.emailVerified`, so the hook honours it strictly. Google reads + * `email_verified` off the id_token; GitHub looks the address up on `/user/emails`, which carries a + * `verified` flag precisely because unverified addresses exist on an account. GitHub's value is only + * available this late — Better Auth computes it AFTER `mapProfileToUser` runs — which is why the + * decision cannot live entirely in the provider mappers. + * - `raw-claim` — the decision is made in `mapProfileToUser` (see `./better-auth-providers`), from the + * raw `email_verified` claim, because Better Auth's own value is useless here: the generic-OAuth + * userinfo path coalesces `email_verified ?? false`, which makes "the IdP did not say" indis- + * tinguishable from "the IdP asserted false". A mapper-returned `emailVerified` spreads last over + * Better Auth's, so it is authoritative, and the hook then passes it through untouched. + * - `never-attests` — the provider cannot carry the claim at all, so there is nothing to honour and the + * row is verified as it always has been. SAML is permanent here: the BoxyHQ bridge's userinfo shape + * has no `email_verified` field, and the provider requests no `openid` scope, so Jackson never mints + * an id_token one could appear in. + */ +export type TSsoEmailVerificationTrust = "attested" | "raw-claim" | "never-attests"; + +/** + * Exhaustive by construction: `TSsoIdentityProvider` is the closed SSO subset of the Prisma enum, so a + * new provider fails typecheck here until someone decides how far to trust its claim. + */ +export const SSO_EMAIL_VERIFICATION_TRUST: Record = { + google: "attested", + github: "attested", + azuread: "raw-claim", + openid: "raw-claim", + saml: "never-attests", +}; + +/** + * Two residuals worth naming, so nobody reads this table as a stronger guarantee than it gives: + * + * - **azuread** emits no `email_verified` at all — not in its id_tokens, not from Graph's + * `/oidc/userinfo`. Its mapper falls back to `xms_edov`, Microsoft's own "is this address proven" + * claim, but that is OPTIONAL and off unless the tenant enables it on the app registration. On a + * default Entra setup nothing is asserted, so every sign-up resolves verified — the same as before + * this change. Entra's `email` is a mutable directory attribute, so that residual is real. + * - **github** is `attested` on Better Auth's `/user/emails` lookup, which coalesces a FAILED lookup to + * the same `false` as a genuine denial (`emails?.find(...)?.verified ?? false`). A registration + * lacking the email permission therefore reads as "GitHub says unverified" for every user rather than + * "GitHub could not say" — noisy and over-strict, never over-permissive. + */ + +/** + * Resolve a provider's RAW `email_verified` claim, for the `raw-claim` providers whose mappers read it + * straight off the profile (ENG-2589). + * + * Deliberately asymmetric, and this is the whole point of reading the raw claim rather than Better + * Auth's coalesced value: + * + * - asserted `false` → NOT verified. This is the case the ticket exists for: an IdP that permits + * self-registration with an unverified address must not yield a verified Formbricks account. + * - asserted `true` → verified. + * - **absent → verified.** An IdP that never sends the claim is not asserting anything, and treating + * its silence as a denial would flip every new user to unverified on upgrade for any self-hosted + * instance whose IdP omits it — a behaviour change we do not inflict on self-hosters to fix a case + * they are not in. Microsoft Graph's `/oidc/userinfo` omits it for most tenants, so this is the + * common path, not a corner. + * + * OIDC types the claim as a boolean, but real IdPs serialise it in whatever their backend produces, so + * a denial is recognised in the shapes it actually arrives in: the boolean, the string in any casing + * (`"false"`, `"False"` from a Python-derived provider, `"FALSE"`), and the numeric forms `0` / `"0"`. + * Matching only the exact boolean would fail OPEN on every one of those — an IdP saying "not verified" + * in a spelling we did not anticipate would mint a verified account, which is precisely the bug class + * this function exists to close. Nothing legitimate is lost by being broad here: no provider sends + * `"False"` or `0` to mean verified. + * + * Affirmative spellings need no cases of their own — they are not denials, so they reach verified via + * the default, which is the same place an absent claim lands. + */ +const DENIAL_CLAIM_VALUES: ReadonlySet = new Set(["false", "0"]); + +export const resolveEmailVerifiedFromRawClaim = (rawClaim: unknown): boolean => { + if (rawClaim === false || rawClaim === 0) return false; + if (typeof rawClaim === "string" && DENIAL_CLAIM_VALUES.has(rawClaim.trim().toLowerCase())) return false; + return true; +}; + +/** + * The value written to `User.emailVerified` on SSO sign-up — the last decision before the INSERT, made + * in `user.create.before` (see `./better-auth-hooks`). + * + * `attested` and `raw-claim` both resolve to "honour what reached us", differing only in WHERE the + * claim was read: Better Auth computed it for the attested pair, our own mapper computed it for the + * raw-claim pair, and either way it arrives here as `user.emailVerified`. Strictly `=== true`, so an + * absent or malformed value is never mistaken for attestation — the `raw-claim` mappers have already + * turned a genuinely absent claim into `true` by then, so nothing is lost by being strict here. + */ +export const resolveSsoEmailVerifiedForCreate = ( + provider: TSsoIdentityProvider, + betterAuthEmailVerified: boolean | undefined +): boolean => + SSO_EMAIL_VERIFICATION_TRUST[provider] === "never-attests" ? true : betterAuthEmailVerified === true; diff --git a/apps/web/modules/ee/sso/lib/provider-normalization.ts b/apps/web/modules/ee/sso/lib/provider-normalization.ts index d949ee4a8bc7..ab28f002a8a9 100644 --- a/apps/web/modules/ee/sso/lib/provider-normalization.ts +++ b/apps/web/modules/ee/sso/lib/provider-normalization.ts @@ -1,5 +1,13 @@ import type { IdentityProvider } from "@formbricks/database/prisma"; +/** + * The SSO subset of `IdentityProvider`: every value except `email`, which denotes a credential account + * and can never come out of an SSO callback. Narrowing `normalizeSsoProvider` to this is what lets a + * per-provider policy table (see `./email-verification-policy`) be exhaustive — adding a provider to + * the Prisma enum then fails typecheck until the policy names it, rather than falling into a default. + */ +export type TSsoIdentityProvider = Exclude; + const SSO_PROVIDER_MAP = { google: "google", github: "github", @@ -7,16 +15,20 @@ const SSO_PROVIDER_MAP = { azuread: "azuread", openid: "openid", saml: "saml", -} as const satisfies Record; +} as const satisfies Record; const LEGACY_SSO_PROVIDER_ALIASES: Partial> = { azuread: ["azure-ad"], }; +// `Object.hasOwn`, not `in`: `in` walks the prototype chain, so "constructor" / "toString" / "__proto__" +// would pass this guard and resolve to a Function typed as a provider. Nothing downstream is reachable +// with such a value today, but the policy table in ./email-verification-policy is documented as +// exhaustive over what this returns, and `in` quietly makes that untrue. const isSupportedSsoProvider = (provider: string): provider is keyof typeof SSO_PROVIDER_MAP => - provider in SSO_PROVIDER_MAP; + Object.hasOwn(SSO_PROVIDER_MAP, provider); -export const normalizeSsoProvider = (provider: string): IdentityProvider | null => { +export const normalizeSsoProvider = (provider: string): TSsoIdentityProvider | null => { const normalizedProviderKey = provider.toLowerCase(); if (!isSupportedSsoProvider(normalizedProviderKey)) { return null; diff --git a/apps/web/modules/ee/sso/lib/sso-provisioning.ts b/apps/web/modules/ee/sso/lib/sso-provisioning.ts index d5bf368a4c19..096388cf996b 100644 --- a/apps/web/modules/ee/sso/lib/sso-provisioning.ts +++ b/apps/web/modules/ee/sso/lib/sso-provisioning.ts @@ -67,7 +67,7 @@ const validateSsoInviteToken = async (email: string, callbackUrl: string): Promi * Better Auth SSO sign-up flow (introduced by the NextAuth→Better Auth migration, ENG-1054). * * MUST be called from `databaseHooks.user.create.before`, which Better Auth runs INSIDE the - * user+account transaction: a `"reject"` there → return `false` → the row rolls back, so no orphan + * user+account transaction: a `"reject"` there → throw an APIError → the row rolls back, so no orphan * user is created (design doc §13; the post-commit `user.create.after` could not reject safely). The * `"provision"` decision (resolved org + flags) is carried to the after-hook, which performs the * membership writes. diff --git a/apps/web/modules/ee/sso/lib/sso-recovery.test.ts b/apps/web/modules/ee/sso/lib/sso-recovery.test.ts index 6f7e5a5edd13..aac8b208838e 100644 --- a/apps/web/modules/ee/sso/lib/sso-recovery.test.ts +++ b/apps/web/modules/ee/sso/lib/sso-recovery.test.ts @@ -1,10 +1,11 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; import { Prisma } from "@formbricks/database/prisma"; +import { logger } from "@formbricks/logger"; import { revokeUserSessionsExcept } from "@/modules/auth/lib/session-revocation"; import { finalizeSuccessfulSignIn } from "@/modules/auth/lib/sign-in-tracking"; import { buildVerificationRequestedPath } from "@/modules/auth/lib/verification-links"; -import { sendVerificationEmail } from "@/modules/email"; +import { sendSsoRecoveryFactorsRemovedEmail, sendVerificationEmail } from "@/modules/email"; import { syncSsoIdentityForUser } from "./account-linking"; import { completeSsoRecovery, getSsoRecoveryFailureRedirectUrl, startSsoRecovery } from "./sso-recovery"; @@ -56,6 +57,7 @@ vi.mock("@/modules/auth/lib/verification-links", async (importOriginal) => { vi.mock("@/modules/email", () => ({ sendVerificationEmail: vi.fn(), + sendSsoRecoveryFactorsRemovedEmail: vi.fn(), })); vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({ @@ -81,12 +83,18 @@ vi.mock("./account-linking", () => ({ isActive: true, identityProvider: true, identityProviderAccountId: true, + twoFactorEnabled: true, }, syncSsoIdentityForUser: vi.fn(), })); describe("sso-recovery", () => { const txUserUpdate = vi.fn(); + const txUserUpdateMany = vi.fn(); + // Both legacy writes go through `user.updateMany`, so the stub answers on the FILTER rather than on + // call order: keyed positionally, reordering the two writes would silently swap what each test below + // is asserting, and a case meant to prove one factor was untouched would be reading the other's count. + const legacyCleared = { twoFactor: 0, password: 0 }; const txTwoFactorDeleteMany = vi.fn(); const txAccountUpdateMany = vi.fn(); const txOauthAccessUpdateMany = vi.fn(); @@ -97,6 +105,7 @@ describe("sso-recovery", () => { const tx = { user: { update: txUserUpdate, + updateMany: txUserUpdateMany, }, twoFactor: { deleteMany: txTwoFactorDeleteMany, @@ -117,6 +126,11 @@ describe("sso-recovery", () => { beforeEach(() => { vi.clearAllMocks(); + legacyCleared.twoFactor = 0; + legacyCleared.password = 0; // post-cutover default: neither legacy column holds anything + txUserUpdateMany.mockImplementation(async ({ where }: { where: Record }) => + "password" in where ? { count: legacyCleared.password } : { count: legacyCleared.twoFactor } + ); txTwoFactorDeleteMany.mockResolvedValue({ count: 1 }); txAccountUpdateMany.mockResolvedValue({ count: 1 }); txOauthAccessUpdateMany.mockResolvedValue({ count: 1 }); @@ -153,6 +167,7 @@ describe("sso-recovery", () => { isActive: true, identityProvider: "email", identityProviderAccountId: null, + twoFactorEnabled: false, }, provider: "google", account: { @@ -191,6 +206,7 @@ describe("sso-recovery", () => { isActive: true, identityProvider: "email", identityProviderAccountId: null, + twoFactorEnabled: false, }, provider: "google", account: { @@ -242,17 +258,30 @@ describe("sso-recovery", () => { data: { backupCodes: null, emailVerified: true, - password: null, twoFactorEnabled: false, twoFactorSecret: null, }, }); + // The two legacy writes, asserted on the CALL and not only on the counts they feed. Their payloads + // are load-bearing for the same reason the exact-match above is: a hash or a latch left behind is a + // factor that survives an account changing hands, and `data: {}` would keep every derived count and + // every assertion below green. + expect(txUserUpdateMany).toHaveBeenCalledWith({ + where: { id: "user_1", password: { not: null } }, + data: { password: null }, + }); + expect(txUserUpdateMany).toHaveBeenCalledWith({ + where: { id: "user_1", twoFactorEnabled: true }, + data: { twoFactorEnabled: false }, + }); // The live stores, which the pre-ENG-2557 implementation never touched. expect(txTwoFactorDeleteMany).toHaveBeenCalledWith({ where: { userId: "user_1" } }); // Scoped by owner, NOT by `providerAccountId`/`issuer`: those are account-key columns and a drifted key // (ENG-2555) would make a key-filtered query walk past a row still holding a live hash. + // `password: { not: null }` narrows to rows that actually held a credential, so the reported count + // is rows CHANGED rather than rows matched — an already-null row must not read as a factor removed. expect(txAccountUpdateMany).toHaveBeenCalledWith({ - where: { userId: "user_1", provider: "credential" }, + where: { userId: "user_1", provider: "credential", password: { not: null } }, data: { password: null }, }); // Post-commit, sparing the caller's own session so the redirect still lands signed in. @@ -279,6 +308,7 @@ describe("sso-recovery", () => { status: "success", newObject: expect.objectContaining({ credentialPasswordsCleared: 1, + legacyPasswordCleared: false, twoFactorRowsRemoved: 1, // Distinct fields, and the stub returns distinct counts (1 access / 2 refresh) on purpose: a // summed field would read 3 either way and could not catch the two being swapped. @@ -307,6 +337,181 @@ describe("sso-recovery", () => { expect(callbackUrl).toBe("http://localhost:3000/environments/env_1"); }); + /** + * ENG-2633. The strip is correct for a squatter and a silent security downgrade for an owner who + * never verified their address — and on the shipped self-hosted defaults, where verification blocks + * nothing, the owner is the likelier of the two. Nothing here can tell them apart, so the account + * holder has to be told what was removed instead. + */ + describe("notifying the user about removed factors", () => { + const asUnverifiedUser = () => + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + id: "user_1", + email: "john.doe@example.com", + locale: "de-DE", + emailVerified: false, + isActive: true, + identityProvider: "email", + identityProviderAccountId: null, + } as never); + + const completeRecovery = () => + completeSsoRecovery({ + intentToken: "test-intent", + sessionUserId: "user_1", + sessionToken: "current-session-token", + }); + + test("names what was removed, in the user's own locale", async () => { + asUnverifiedUser(); + + await completeRecovery(); + + expect(sendSsoRecoveryFactorsRemovedEmail).toHaveBeenCalledWith({ + email: "john.doe@example.com", + locale: "de-DE", + passwordRemoved: true, + twoFactorRemoved: true, + }); + }); + + // Each flag reports what this account actually had, so the mail never claims to have removed a + // factor the user never enrolled. + test("reports only the factors that were really there", async () => { + asUnverifiedUser(); + txTwoFactorDeleteMany.mockResolvedValue({ count: 0 }); // no second factor enrolled + + await completeRecovery(); + + expect(sendSsoRecoveryFactorsRemovedEmail).toHaveBeenCalledWith( + expect.objectContaining({ passwordRemoved: true, twoFactorRemoved: false }) + ); + }); + + /** + * The under-reporting trap. 2FA lives in two stores and the strip clears both, but a user who + * enrolled before the backfill shim landed has only the legacy `User.twoFactorEnabled` latch and no + * `TwoFactor` row — so counting rows alone would tell exactly that user their second factor was left + * alone, on the one occasion it was taken away. + */ + test("reports the second factor for a legacy enrolment with no TwoFactor row", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + id: "user_1", + email: "john.doe@example.com", + locale: "en-US", + emailVerified: false, + isActive: true, + identityProvider: "email", + identityProviderAccountId: null, + } as never); + legacyCleared.twoFactor = 1; // this transaction flipped the legacy latch... + txTwoFactorDeleteMany.mockResolvedValue({ count: 0 }); // ...and there was no row to count + + await completeRecovery(); + + expect(sendSsoRecoveryFactorsRemovedEmail).toHaveBeenCalledWith( + expect.objectContaining({ twoFactorRemoved: true }) + ); + }); + + /** + * The password half of the same trap. A pre-cutover account keeps its hash in `User.password` and + * may have no credential `Account` row at all, so counting only those rows would tell that user + * their password survived — on the one occasion it did not. + */ + test("reports the password for a legacy hash with no credential account", async () => { + asUnverifiedUser(); + txAccountUpdateMany.mockResolvedValue({ count: 0 }); // no credential row... + legacyCleared.password = 1; // ...but a legacy hash was cleared + txTwoFactorDeleteMany.mockResolvedValue({ count: 0 }); // and no second factor was touched + + await completeRecovery(); + + // `twoFactorRemoved: false` is the half that only holds once the two legacy writes are told + // apart — with a shared count it would read `true` off the password's own result. + expect(sendSsoRecoveryFactorsRemovedEmail).toHaveBeenCalledWith( + expect.objectContaining({ passwordRemoved: true, twoFactorRemoved: false }) + ); + }); + + // Recoveries are not locked against each other, and `user` is read before the transaction opens. + // A racing second recovery finds the latch already down, so its filtered write matches nothing and + // it must not mail the owner about a factor the first one removed. + test("a concurrent recovery that changed nothing reports nothing", async () => { + // The stale pre-transaction read still says the latch is up — that is the whole trap. Only the + // filtered write inside the transaction knows this recovery was not the one that flipped it. + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + id: "user_1", + email: "john.doe@example.com", + locale: "de-DE", + emailVerified: false, + isActive: true, + identityProvider: "email", + identityProviderAccountId: null, + twoFactorEnabled: true, + } as never); + legacyCleared.twoFactor = 0; // the other transaction got there first + txTwoFactorDeleteMany.mockResolvedValue({ count: 0 }); + txAccountUpdateMany.mockResolvedValue({ count: 0 }); + + await completeRecovery(); + + expect(sendSsoRecoveryFactorsRemovedEmail).not.toHaveBeenCalled(); + }); + + // `sendEmail` returns false without throwing when SMTP is unconfigured, so the catch never sees it. + // Silence there means a second factor was removed and nobody was told — the exact failure this + // notification exists to prevent, so it has to be attributable in the log. + test("logs when the mail is not delivered rather than failing silently", async () => { + asUnverifiedUser(); + vi.mocked(sendSsoRecoveryFactorsRemovedEmail).mockResolvedValue(false); + + await expect(completeRecovery()).resolves.toBeDefined(); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ userId: "user_1" }), + expect.stringContaining("notification email was not sent") + ); + }); + + // An account with neither factor set has lost nothing, so a mail would be noise. + test("says nothing when there was nothing to remove", async () => { + asUnverifiedUser(); + txTwoFactorDeleteMany.mockResolvedValue({ count: 0 }); + txAccountUpdateMany.mockResolvedValue({ count: 0 }); + + await completeRecovery(); + + expect(sendSsoRecoveryFactorsRemovedEmail).not.toHaveBeenCalled(); + }); + + test("says nothing when the account was already verified and nothing was stripped", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + id: "user_1", + email: "john.doe@example.com", + locale: "en-US", + emailVerified: true, + isActive: true, + identityProvider: "email", + identityProviderAccountId: null, + } as never); + + await completeRecovery(); + + expect(sendSsoRecoveryFactorsRemovedEmail).not.toHaveBeenCalled(); + }); + + // The strip has already committed by this point, so a mailer outage must not turn a completed + // recovery into a failed sign-in — the user would be locked out of an account that has already + // changed shape. + test("completes the recovery even when the mail cannot be sent", async () => { + asUnverifiedUser(); + vi.mocked(sendSsoRecoveryFactorsRemovedEmail).mockRejectedValue(new Error("smtp down")); + + await expect(completeRecovery()).resolves.toBe("http://localhost:3000/environments/env_1"); + expect(finalizeSuccessfulSignIn).toHaveBeenCalled(); + }); + }); + test("does not clear local auth material for already verified users", async () => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ id: "user_1", @@ -365,8 +570,10 @@ describe("sso-recovery", () => { sessionToken: "current-session-token", }); + // `password: { not: null }` narrows to rows that actually held a credential, so the reported count + // is rows CHANGED rather than rows matched — an already-null row must not read as a factor removed. expect(txAccountUpdateMany).toHaveBeenCalledWith({ - where: { userId: "user_1", provider: "credential" }, + where: { userId: "user_1", provider: "credential", password: { not: null } }, data: { password: null }, }); expect(txTwoFactorDeleteMany).toHaveBeenCalledOnce(); diff --git a/apps/web/modules/ee/sso/lib/sso-recovery.ts b/apps/web/modules/ee/sso/lib/sso-recovery.ts index ae57f71fbeeb..995cf92c4152 100644 --- a/apps/web/modules/ee/sso/lib/sso-recovery.ts +++ b/apps/web/modules/ee/sso/lib/sso-recovery.ts @@ -10,7 +10,7 @@ import { finalizeSuccessfulSignIn } from "@/modules/auth/lib/sign-in-tracking"; import { buildVerificationRequestedPath } from "@/modules/auth/lib/verification-links"; import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler"; import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log"; -import { sendVerificationEmail } from "@/modules/email"; +import { sendSsoRecoveryFactorsRemovedEmail, sendVerificationEmail } from "@/modules/email"; import { LINKED_SSO_LOOKUP_SELECT, TSsoAccountLinkInput, @@ -70,7 +70,9 @@ const queueSsoRecoveryAuditEvent = ({ ...(reclaimed ? { credentialPasswordsCleared: reclaimed.credentialPasswordsCleared, + legacyPasswordCleared: reclaimed.legacyPasswordCleared, twoFactorRowsRemoved: reclaimed.twoFactorRowsRemoved, + legacyTwoFactorDisarmed: reclaimed.legacyTwoFactorDisarmed, // Reported separately, not summed. In this configuration access tokens are self-contained // JWTs that are never persisted (see the revocation block below), so the access count is // ~always 0 and a combined "grants" total would be the refresh count wearing a plural name — @@ -95,7 +97,16 @@ const queueSsoRecoveryAuditEvent = ({ */ type TReclaimOutcome = { credentialPasswordsCleared: number; + /** The pre-cutover `User.password` hash, which lives in a different store to the credential account. */ + legacyPasswordCleared: boolean; twoFactorRowsRemoved: number; + /** + * Whether THIS transaction flipped the legacy `User.twoFactorEnabled` latch. 2FA lives in two stores and the + * strip clears both, but a user who enrolled before the backfill shim landed has only the legacy + * columns — no `TwoFactor` row for `twoFactorRowsRemoved` to count. Reporting the row count alone + * would tell that user, and the audit trail, that their second factor was left alone. + */ + legacyTwoFactorDisarmed: boolean; oauthAccessTokensRevoked: number; oauthRefreshTokensRevoked: number; oauthConsentsRevoked: number; @@ -145,7 +156,7 @@ type TReclaimOutcome = { * AsyncLocalStorage, so a revocation issued in here would execute outside `tx` and survive a rollback. * * Not locked against concurrent recoveries (upstream takes a DB advisory lock for its equivalent). Every - * write here is idempotent — two `deleteMany`/`updateMany` calls and an update to fixed values — so a race + * write here is idempotent — the `deleteMany`/`updateMany` calls and an update to fixed values — so a race * converges on the same state rather than corrupting it. */ const reclaimUnverifiedLocalAuthIfNeeded = async ({ @@ -181,9 +192,9 @@ const reclaimUnverifiedLocalAuthIfNeeded = async ({ // told. Correct for a squatter, a silent downgrade for the owner. // // Left as-is on purpose: there is no signal here that separates the two populations, and weakening the - // guard would reopen the takeover. What is missing is telling the user — mail them what was removed and - // prompt re-enrolment. That needs a new transactional template, so it is tracked separately rather than - // widened into a fix that backports to two release branches. + // guard would reopen the takeover. What was missing was telling the user, and the completion path now + // does — `sendSsoRecoveryFactorsRemovedEmail` names what was removed and links to re-enrolment + // (ENG-2633). The in-app re-enrolment prompt is still open on that ticket. if (user.emailVerified) { return null; } @@ -193,19 +204,42 @@ const reclaimUnverifiedLocalAuthIfNeeded = async ({ // // The legacy columns: the 2FA pair is load-bearing (see the backfill note above); `password` is a no-op // for post-cutover users and kept only so a pre-cutover row cannot survive here. + // The latch is flipped by its own filtered write so the count reflects what THIS transaction + // changed. `user` was read before the transaction opened, and recoveries are deliberately not + // locked against each other, so deriving the flag from that stale read lets two concurrent + // recoveries both report they disarmed a factor only one of them touched — a duplicate mail and a + // false audit record. The unconditional nulls below still run for everyone: a secret left at rest + // on an account that changed hands is the thing the strip exists to prevent, and filtering those + // on the latch would skip an account whose `twoFactorEnabled` was already false. + const legacyTwoFactorRows = await tx.user.updateMany({ + where: { id: user.id, twoFactorEnabled: true }, + data: { twoFactorEnabled: false }, + }); await tx.user.update({ where: { id: user.id }, data: { backupCodes: null, emailVerified: true, - password: null, twoFactorEnabled: false, twoFactorSecret: null, }, }); + // The legacy `User.password`, cleared as its own counted write rather than a field on the update + // above. Pre-cutover accounts keep their hash here and may have no credential `Account` row at all, + // so counting only those rows would tell such a user — and the audit trail — that nothing was taken + // from them. `password: { not: null }` makes the count rows CHANGED, and reading it this way keeps + // the hash itself out of the lookup select and out of memory. + const legacyPasswordRows = await tx.user.updateMany({ + where: { id: user.id, password: { not: null } }, + data: { password: null }, + }); const twoFactorRows = await tx.twoFactor.deleteMany({ where: { userId: user.id } }); const credentialRows = await tx.account.updateMany({ - where: { userId: user.id, provider: "credential" }, + // `password: { not: null }` narrows this to rows that actually HELD a credential. `updateMany` + // reports rows matched, not rows changed, so without it an account whose password was already null + // reports one cleared — which the audit trail records as a factor taken away, and the notification + // mail tells the user they lost a password they never had. + where: { userId: user.id, provider: "credential", password: { not: null } }, data: { password: null }, }); @@ -245,7 +279,9 @@ const reclaimUnverifiedLocalAuthIfNeeded = async ({ return { credentialPasswordsCleared: credentialRows.count, + legacyPasswordCleared: legacyPasswordRows.count > 0, twoFactorRowsRemoved: twoFactorRows.count, + legacyTwoFactorDisarmed: legacyTwoFactorRows.count > 0, oauthAccessTokensRevoked: accessRows.count, oauthRefreshTokensRevoked: refreshRows.count, oauthConsentsRevoked: consentRows.count, @@ -513,6 +549,44 @@ export const completeSsoRecovery = async ({ } } + // Tell the account holder what recovery removed (ENG-2633). The strip is correct for a squatter and a + // silent security downgrade for the owner — and on a default self-hosted install, where verification + // blocks nothing, the owner is the likelier of the two. Nothing here can distinguish them, so the + // answer is to say what happened rather than to weaken the guard. + // + // After commit and best-effort, for the same reason the session revocation above is: the strip has + // already landed, and a mailer failure must not turn a completed recovery into a failed sign-in. + const twoFactorRemoved = Boolean( + reclaimed && (reclaimed.twoFactorRowsRemoved > 0 || reclaimed.legacyTwoFactorDisarmed) + ); + const passwordRemoved = Boolean( + reclaimed && (reclaimed.credentialPasswordsCleared > 0 || reclaimed.legacyPasswordCleared) + ); + if (passwordRemoved || twoFactorRemoved) { + try { + const sent = await sendSsoRecoveryFactorsRemovedEmail({ + email: user.email, + locale: user.locale, + passwordRemoved, + twoFactorRemoved, + }); + // `sendEmail` returns false without throwing when SMTP is unconfigured, so the catch below never + // sees it. Silence there would mean a user's second factor was removed and nobody — not them, not + // the operator — was told, which is the whole failure this notification exists to prevent. + if (!sent) { + logger.error( + { userId: user.id, passwordRemoved, twoFactorRemoved }, + "SSO recovery removed local sign-in factors but the notification email was not sent" + ); + } + } catch (error) { + logger.error( + { error, userId: user.id }, + "Failed to notify the user that SSO recovery removed their local sign-in factors" + ); + } + } + try { await finalizeSuccessfulSignIn({ userId: user.id, diff --git a/apps/web/modules/ee/unify-feedback/sources/components/mapping-field.tsx b/apps/web/modules/ee/unify-feedback/sources/components/mapping-field.tsx index ab27e7ce19aa..731517149540 100644 --- a/apps/web/modules/ee/unify-feedback/sources/components/mapping-field.tsx +++ b/apps/web/modules/ee/unify-feedback/sources/components/mapping-field.tsx @@ -314,7 +314,7 @@ export const FormTargetField = ({ emptyDropdownText={t("workspace.surveys.edit.no_option_found")} showSearch disabled={disabled} - comboboxClasses="h-9 w-full max-w-none [&_[role=combobox]]:h-9" + comboboxClasses="h-9 w-full max-w-none" /> {!isEnum && mapping?.staticValue && mapping.staticValue !== "$now" && ( diff --git a/apps/web/modules/email/index.tsx b/apps/web/modules/email/index.tsx index cd72e88d4c42..82ae0b3d69ff 100644 --- a/apps/web/modules/email/index.tsx +++ b/apps/web/modules/email/index.tsx @@ -11,6 +11,7 @@ import { renderNewEmailVerification, renderPasswordResetNotifyEmail, renderResponseFinishedEmail, + renderSsoRecoveryFactorsRemovedEmail, renderVerificationEmail, } from "@formbricks/email"; import { TEmailTemplateLegalProps } from "@formbricks/email/src/types/email"; @@ -261,6 +262,43 @@ export const sendPasswordResetNotifyEmail = async (user: { }); }; +/** + * Tell a user that SSO recovery removed the local sign-in factors from their account (ENG-2633). + * + * Recovery strips the password and any second factor from an account whose address was never proven, + * because marking it verified would otherwise leave an attacker who registered on someone else's + * address holding a live credential. No signal separates that attacker from an owner who simply never + * clicked a verification link, so the strip is unconditional and this mail is what keeps the + * legitimate case from being a silent downgrade: it names what went and links to re-enrolment. + */ +export const sendSsoRecoveryFactorsRemovedEmail = async ({ + email, + locale, + passwordRemoved, + twoFactorRemoved, +}: { + email: string; + locale: TUserLocale; + passwordRemoved: boolean; + twoFactorRemoved: boolean; +}): Promise => { + const t = await getTranslate(locale); + const html = await renderSsoRecoveryFactorsRemovedEmail({ + passwordRemoved, + twoFactorRemoved, + // The account profile page is where both factors this mail can name are re-enrolled — the password + // form and the 2FA card both live there. There is no separate /settings/security route. + securitySettingsLink: `${WEBAPP_URL}/account/settings/profile`, + t, + ...legalProps, + }); + return await sendEmail({ + to: email, + subject: t("emails.sso_recovery_factors_removed_email_subject"), + html, + }); +}; + export const sendInviteMemberEmail = async ( inviteId: string, email: string, diff --git a/apps/web/modules/email/sso-recovery-factors-removed-email.test.ts b/apps/web/modules/email/sso-recovery-factors-removed-email.test.ts new file mode 100644 index 000000000000..825a5532ab06 --- /dev/null +++ b/apps/web/modules/email/sso-recovery-factors-removed-email.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "vitest"; +import { renderSsoRecoveryFactorsRemovedEmail } from "@formbricks/email"; + +const t = (key: string): string => { + const translations: Record = { + "emails.sso_recovery_factors_removed_email_heading": "Sign-in factors removed from your account", + "emails.sso_recovery_factors_removed_email_text": + "You just signed in with single sign-on for the first time.", + "emails.sso_recovery_factors_removed_email_password": "Your password was removed.", + "emails.sso_recovery_factors_removed_email_two_factor": "Two-factor authentication was removed.", + "emails.sso_recovery_factors_removed_email_sign_in_hint": "You can keep signing in with single sign-on.", + "emails.sso_recovery_factors_removed_email_review_security": "Review security settings", + "emails.sso_recovery_factors_removed_email_did_not_expect": + "If you did not just sign in, contact support.", + "emails.email_footer_text_1": "Have a great day!", + "emails.email_footer_text_2": "The Formbricks Team", + "emails.email_template_text_1": "This email was sent via Formbricks.", + }; + return translations[key] ?? key; +}; + +/** + * ENG-2633. This mail exists to tell someone their password or second factor is gone, so the two things + * that must hold are that it names only what was actually removed, and that the place it sends them to + * re-enrol is real. A smoke test caught the link 404ing (`/settings/security` does not exist); that is + * what the last test here pins. + */ +describe("renderSsoRecoveryFactorsRemovedEmail", () => { + const render = (overrides: { passwordRemoved: boolean; twoFactorRemoved: boolean }) => + renderSsoRecoveryFactorsRemovedEmail({ + securitySettingsLink: "https://app.formbricks.com/account/settings/profile", + t, + ...overrides, + }); + + test("names both factors when both were removed", async () => { + const html = await render({ passwordRemoved: true, twoFactorRemoved: true }); + + expect(html).toContain("Your password was removed."); + expect(html).toContain("Two-factor authentication was removed."); + }); + + // Claiming a factor was removed that the user never had would be alarming and wrong. + test("omits the second factor when only the password was removed", async () => { + const html = await render({ passwordRemoved: true, twoFactorRemoved: false }); + + expect(html).toContain("Your password was removed."); + expect(html).not.toContain("Two-factor authentication was removed."); + }); + + test("omits the password when only the second factor was removed", async () => { + const html = await render({ passwordRemoved: false, twoFactorRemoved: true }); + + expect(html).not.toContain("Your password was removed."); + expect(html).toContain("Two-factor authentication was removed."); + }); + + /** + * The re-enrolment link is the whole point of the mail — a dead one leaves the user with a factor + * removed and nowhere to go. `/account/settings/profile` is the page that hosts both the password + * form and the 2FA card; there is no `/settings/security` route, which is what shipped first. + */ + test("links to the account page that actually hosts both factors", async () => { + const html = await render({ passwordRemoved: true, twoFactorRemoved: true }); + + expect(html).toContain("https://app.formbricks.com/account/settings/profile"); + expect(html).not.toContain("/settings/security"); + }); +}); diff --git a/apps/web/modules/survey/editor/components/survey-editor.tsx b/apps/web/modules/survey/editor/components/survey-editor.tsx index 6ea51c881d23..12ab2ddfed9e 100644 --- a/apps/web/modules/survey/editor/components/survey-editor.tsx +++ b/apps/web/modules/survey/editor/components/survey-editor.tsx @@ -185,7 +185,6 @@ export const SurveyEditor = ({ responseCount={responseCount} finishedResponseCount={finishedResponseCount} selectedLanguageCode={selectedLanguageCode} - setSelectedLanguageCode={setSelectedLanguageCode} isCxMode={isCxMode} locale={locale} setIsCautionDialogOpen={setIsCautionDialogOpen} diff --git a/apps/web/modules/survey/editor/components/survey-menu-bar.tsx b/apps/web/modules/survey/editor/components/survey-menu-bar.tsx index efeb2ddc1fe9..f821a39fc288 100644 --- a/apps/web/modules/survey/editor/components/survey-menu-bar.tsx +++ b/apps/web/modules/survey/editor/components/survey-menu-bar.tsx @@ -17,9 +17,11 @@ import { ZSurveyEndScreenCard, ZSurveyRedirectUrlCard, } from "@formbricks/types/surveys/types"; +import { structuredClone } from "@/lib/pollyfills/structuredClone"; import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { isDeepEqual } from "@/lib/utils/object"; import { createSegmentAction } from "@/modules/ee/contacts/segments/actions"; +import { hasUnsavedSurveyChanges } from "@/modules/survey/editor/lib/unsaved-changes"; import { scrollElementCardIntoView } from "@/modules/survey/editor/lib/utils"; import { TSurveyDraft } from "@/modules/survey/editor/types/survey"; import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert"; @@ -41,7 +43,6 @@ interface SurveyMenuBarProps { responseCount: number; finishedResponseCount: number; selectedLanguageCode: string; - setSelectedLanguageCode: (selectedLanguage: string) => void; isCxMode: boolean; locale: string; setIsCautionDialogOpen: (open: boolean) => void; @@ -81,6 +82,11 @@ export const SurveyMenuBar = ({ const localSurveyRef = useRef(localSurvey); const surveyRef = useRef(survey); const isSurveySavingRef = useRef(isSurveySaving); + // A snapshot of what the last successful save returned. The `survey` prop is the other persisted + // state; the two are not interchangeable, so the dirty checks below compare against both. Cloned + // rather than aliased, so a later in-place edit of the editor's own survey cannot drag the + // snapshot along with it and hide the change. + const lastSavedSurveyRef = useRef(null); useEffect(() => { if (audiencePrompt && activeId === "settings") { @@ -120,7 +126,7 @@ export const SurveyMenuBar = ({ return; } - if (!isDeepEqual(localSurvey, survey)) { + if (hasUnsavedSurveyChanges(localSurvey, [survey, lastSavedSurveyRef.current])) { e.preventDefault(); return (e.returnValue = warningText); } @@ -172,10 +178,7 @@ export const SurveyMenuBar = ({ }); const handleBack = () => { - const { updatedAt, ...localSurveyRest } = localSurvey; - const { updatedAt: _, ...surveyRest } = survey; - - if (!isDeepEqual(localSurveyRest, surveyRest)) { + if (hasUnsavedSurveyChanges(localSurvey, [survey, lastSavedSurveyRef.current])) { setConfirmDialogOpen(true); } else { router.back(); @@ -346,12 +349,10 @@ export const SurveyMenuBar = ({ // Skip if already saving, publishing, or auto-saving if (isAutoSavingRef.current || isSurveySavingRef.current || isSurveyPublishingRef.current) return; - // Check for changes using refs (avoids re-creating interval on every change) - const { updatedAt: localUpdatedAt, ...localSurveyRest } = localSurveyRef.current; - const { updatedAt: surveyUpdatedAt, ...surveyRest } = surveyRef.current; - - // Skip if no changes - if (isDeepEqual(localSurveyRest, surveyRest)) return; + // Check for changes using refs (avoids re-creating interval on every change), and skip if + // there are none + if (!hasUnsavedSurveyChanges(localSurveyRef.current, [surveyRef.current, lastSavedSurveyRef.current])) + return; isAutoSavingRef.current = true; @@ -376,6 +377,7 @@ export const SurveyMenuBar = ({ // This keeps the UI stable while still tracking that changes have been saved. // The comparison uses refs, so this prevents unnecessary re-saves. surveyRef.current = { ...savedData }; + lastSavedSurveyRef.current = structuredClone(savedData); isSuccessfullySavedRef.current = true; setLastAutoSaved(new Date()); } @@ -403,6 +405,7 @@ export const SurveyMenuBar = ({ setIsSurveySaving(false); if (updatedSurveyResponse?.data) { setLocalSurvey(updatedSurveyResponse.data); + lastSavedSurveyRef.current = structuredClone(updatedSurveyResponse.data); toast.success(t("workspace.surveys.edit.changes_saved")); isSuccessfullySavedRef.current = true; router.refresh(); @@ -475,6 +478,7 @@ export const SurveyMenuBar = ({ // doesn't linger in editor state and get echoed back on the next update. const { isSecondPublish: _isSecondPublish, ...updatedSurvey } = updatedSurveyResponse.data; setLocalSurvey(updatedSurvey); + lastSavedSurveyRef.current = structuredClone(updatedSurvey); toast.success(t("workspace.surveys.edit.changes_saved")); // Set flag to prevent beforeunload warning during router.refresh() isSuccessfullySavedRef.current = true; diff --git a/apps/web/modules/survey/editor/components/validation-rule-value-input.tsx b/apps/web/modules/survey/editor/components/validation-rule-value-input.tsx index 98b87b6d96c6..96e7399c8456 100644 --- a/apps/web/modules/survey/editor/components/validation-rule-value-input.tsx +++ b/apps/web/modules/survey/editor/components/validation-rule-value-input.tsx @@ -129,6 +129,15 @@ export const ValidationRuleValueInput = ({ type={htmlInputType} value={currentValue ?? ""} onChange={(e) => onChange(e.target.value)} + // Browsers accept scientific notation in a number field, so `1e5` would silently store + // 100000 and a bare `e` would store 0 while the field still shows the typed text. `.` and + // `-` stay allowed: decimal and negative thresholds are valid per the rule schemas. + // Modifier chords are let through — Ctrl/Cmd+E moves the caret in Chrome/Safari text fields + // and Ctrl/Cmd++ zooms, and swallowing those while this field has focus is not the intent. + onKeyDown={(e) => { + if (e.ctrlKey || e.metaKey || e.altKey) return; + if (config.valueType === "number" && ["e", "E", "+"].includes(e.key)) e.preventDefault(); + }} placeholder={config.valuePlaceholder} className="h-9 min-w-[80px] bg-white" min={config.valueType === "number" ? 0 : ""} diff --git a/apps/web/modules/survey/editor/components/validation-rules-editor.tsx b/apps/web/modules/survey/editor/components/validation-rules-editor.tsx index cc89971148f4..21533f52ee16 100644 --- a/apps/web/modules/survey/editor/components/validation-rules-editor.tsx +++ b/apps/web/modules/survey/editor/components/validation-rules-editor.tsx @@ -18,11 +18,13 @@ import { import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle"; import { RULE_TYPE_CONFIG } from "../lib/validation-rules-config"; import { + applyRuleDeletion, getAddressFields, getContactInfoFields, getDefaultRuleValue, getRuleLabels, parseRuleValue, + shouldResetInputTypeToText, } from "../lib/validation-rules-helpers"; import { RULES_BY_INPUT_TYPE, createRuleParams, getAvailableRuleTypes } from "../lib/validation-rules-utils"; import { ValidationLogicSelector } from "./validation-logic-selector"; @@ -94,16 +96,8 @@ export const ValidationRulesEditor = ({ const handleDisable = () => { onUpdateValidation({ rules: [], logic: validationLogic }); - // Reset inputType to "text" when disabling validation for OpenText elements. - // Without this, the HTML input keeps its type (e.g. "url"), which still enforces - // browser-native format validation even though the user toggled validation off. - if ( - elementType === TSurveyElementTypeEnum.OpenText && - onUpdateInputType && - inputType !== undefined && - inputType !== "text" - ) { - onUpdateInputType("text"); + if (shouldResetInputTypeToText(elementType, 0, inputType)) { + onUpdateInputType?.("text"); } }; @@ -146,8 +140,18 @@ export const ValidationRulesEditor = ({ }; const handleDeleteRule = (ruleId: string) => { - const updated = validationRules.filter((r) => r.id !== ruleId); - onUpdateValidation({ rules: updated, logic: validationLogic }); + // Deleting the last rule leaves the section reading as "validation off" (isEnabled is derived + // from the rule count), so it has to reset inputType exactly like handleDisable does. + const { rules, resetInputTypeToText } = applyRuleDeletion( + validationRules, + ruleId, + elementType, + inputType + ); + onUpdateValidation({ rules, logic: validationLogic }); + if (resetInputTypeToText) { + onUpdateInputType?.("text"); + } }; const handleRuleTypeChange = (ruleId: string, newType: TValidationRuleType) => { diff --git a/apps/web/modules/survey/editor/lib/unsaved-changes.test.ts b/apps/web/modules/survey/editor/lib/unsaved-changes.test.ts new file mode 100644 index 000000000000..c1718a14e3ce --- /dev/null +++ b/apps/web/modules/survey/editor/lib/unsaved-changes.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "vitest"; +import { TSurvey } from "@formbricks/types/surveys/types"; +import { hasUnsavedSurveyChanges } from "./unsaved-changes"; + +const baseSurvey = { + id: "survey_1", + name: "My survey", + status: "draft", + publishOn: null, + closeOn: null, + displayPercentage: null, + segment: null, + updatedAt: new Date("2026-01-01T00:00:00.000Z"), +} as unknown as TSurvey; + +const surveyWith = (overrides: Record): TSurvey => + ({ ...baseSurvey, ...overrides }) as unknown as TSurvey; + +describe("hasUnsavedSurveyChanges", () => { + test("reads clean when the editor matches the survey prop", () => { + expect(hasUnsavedSurveyChanges(surveyWith({}), [baseSurvey])).toBe(false); + }); + + test("ignores updatedAt, which every write moves", () => { + const local = surveyWith({ updatedAt: new Date("2026-03-01T00:00:00.000Z") }); + + expect(hasUnsavedSurveyChanges(local, [baseSurvey])).toBe(false); + }); + + // The reported bug. A draft save sets `localSurvey` from the action's response and then + // `router.refresh()` re-reads the same survey into the `survey` prop; the two disagree with zero + // user edits, so comparing against the prop alone warned about work that was already saved. + test("reads clean against the saved response even when the refreshed prop disagrees", () => { + const savedResponse = surveyWith({ publishOn: null }); + const refreshedProp = surveyWith({ publishOn: new Date("2026-02-01T00:00:00.000Z") }); + const local = surveyWith({ publishOn: null }); + + expect(hasUnsavedSurveyChanges(local, [refreshedProp, savedResponse])).toBe(false); + // Drop the saved response and the same state reads dirty again — that is the old behaviour. + expect(hasUnsavedSurveyChanges(local, [refreshedProp])).toBe(true); + }); + + test("still warns on an edit made after the save", () => { + const savedResponse = surveyWith({ name: "My survey" }); + const local = surveyWith({ name: "My survey (edited)" }); + + expect(hasUnsavedSurveyChanges(local, [baseSurvey, savedResponse])).toBe(true); + }); + + test("skips persisted states that are not there yet", () => { + expect(hasUnsavedSurveyChanges(baseSurvey, [null, undefined, baseSurvey])).toBe(false); + expect(hasUnsavedSurveyChanges(baseSurvey, [null, undefined])).toBe(true); + }); +}); diff --git a/apps/web/modules/survey/editor/lib/unsaved-changes.ts b/apps/web/modules/survey/editor/lib/unsaved-changes.ts new file mode 100644 index 000000000000..4513485ba50d --- /dev/null +++ b/apps/web/modules/survey/editor/lib/unsaved-changes.ts @@ -0,0 +1,29 @@ +import { TSurvey } from "@formbricks/types/surveys/types"; +import { isDeepEqual } from "@/lib/utils/object"; + +/** Stands in for `updatedAt` on both sides of a comparison, so its real value never decides one. */ +const IGNORED_UPDATED_AT = new Date(0); + +/** + * `updatedAt` moves on every write and is never something the user typed, so it can't take part in a + * dirty check. Flattened to a fixed value rather than stripped, which keeps the result a `TSurvey`. + */ +const ignoringUpdatedAt = (survey: TSurvey): TSurvey => ({ ...survey, updatedAt: IGNORED_UPDATED_AT }); + +/** + * Whether the editor holds changes that are not persisted. + * + * A save leaves behind two representations of the same stored survey: the one the server action + * returns, which `localSurvey` is set to, and the one the `router.refresh()` that follows re-reads + * into the `survey` prop. They reach the client by different routes and do not always arrive + * identical, so checking `localSurvey` against only one of them reports unsaved work the moment a + * save completes. Pass every survey known to be persisted; the editor is clean when it matches any + * of them, and the guard still fires on the first genuine edit, which matches none. + */ +export const hasUnsavedSurveyChanges = ( + localSurvey: TSurvey, + persistedSurveys: readonly (TSurvey | null | undefined)[] +): boolean => { + const local = ignoringUpdatedAt(localSurvey); + return !persistedSurveys.some((persisted) => persisted && isDeepEqual(local, ignoringUpdatedAt(persisted))); +}; diff --git a/apps/web/modules/survey/editor/lib/validation-rules-helpers.test.ts b/apps/web/modules/survey/editor/lib/validation-rules-helpers.test.ts index 5757a41988af..a99aecef8363 100644 --- a/apps/web/modules/survey/editor/lib/validation-rules-helpers.test.ts +++ b/apps/web/modules/survey/editor/lib/validation-rules-helpers.test.ts @@ -5,15 +5,19 @@ import type { TSurveyMultipleChoiceElement, TSurveyRankingElement, } from "@formbricks/types/surveys/elements"; +import type { TValidationRule } from "@formbricks/types/surveys/validation-rules"; import { RULE_TYPE_CONFIG } from "./validation-rules-config"; import { + applyRuleDeletion, getAddressFields, getContactInfoFields, getDefaultRuleValue, getRuleLabels, normalizeFileExtension, parseRuleValue, + shouldResetInputTypeToText, } from "./validation-rules-helpers"; +import { createRuleParams } from "./validation-rules-utils"; // Mock translation function const mockT = (key: string): string => key; @@ -234,4 +238,196 @@ describe("parseRuleValue", () => { const value = parseRuleValue("equals", "test-value", config); expect(value).toBe("test-value"); }); + + // Scientific notation reaches the field by paste (onKeyDown only blocks typing), and `Number()` + // reads "1e5" as 100000 — a value the user never entered and cannot read back from the input. + describe("number value type rejects scientific notation", () => { + const config = RULE_TYPE_CONFIG.isGreaterThan; + + test.each([ + ["1e5", 0], + ["1E5", 0], + ["e", 0], + ["-1e5", 0], + ["1e-5", 0], + ["0x10", 0], + ["Infinity", 0], + ])("parses %j as %i", (input, expected) => { + expect(parseRuleValue("isGreaterThan", input, config)).toBe(expected); + }); + + // Found in review: the regex admits these, so only the finite check stops them. A long digit run + // overflows to Infinity, which is truthy and so survived the old `Number(value) || 0`; it then + // survived the truthy guard in createMaxParams too and stored `{ max: null }`, because + // JSON.stringify writes Infinity as null. The `-` / `.` / `-.` cases were already caught by the + // old truthy guard and are pinned here so the finite check is not credited with more than it does. + test.each([ + ["a 400-digit run overflowing to Infinity", "9".repeat(400)], + ["a lone minus", "-"], + ["a lone decimal point", "."], + ["a minus and a point", "-."], + ])("parses %s as 0 rather than a non-finite number", (_label, input) => { + const parsed = parseRuleValue("isGreaterThan", input, config); + expect(parsed).toBe(0); + expect(Number.isFinite(parsed)).toBe(true); + }); + + test("the overflow never reaches the stored rule params", () => { + const parsed = parseRuleValue("isLessThan", "9".repeat(400), RULE_TYPE_CONFIG.isLessThan); + expect(createRuleParams("isLessThan", parsed)).toEqual({ max: 100 }); + }); + + test.each([ + ["10", 10], + ["2.5", 2.5], + [".5", 0.5], + // minValue/maxValue/isGreaterThan/isLessThan are bare z.number(), so a negative threshold + // is legitimate and must survive the guard. + ["-3", -3], + ["-2.5", -2.5], + [" 7 ", 7], + ])("still parses %j as %d", (input, expected) => { + expect(parseRuleValue("isGreaterThan", input, config)).toBe(expected); + }); + }); +}); + +// The editor derives "validation is on" from the rule count, so every path that empties the list +// must reset inputType — otherwise the section reads as off while the element still carries +// inputType: "number" | "email" | "url" | "phone", the Long answer toggle stays disabled, and the +// rendered input keeps enforcing browser-native format validation for respondents. +describe("shouldResetInputTypeToText", () => { + test.each(["number", "email", "url", "phone"] as const)( + "resets when the last rule is removed from an OpenText element with inputType %s", + (inputType) => { + expect(shouldResetInputTypeToText(TSurveyElementTypeEnum.OpenText, 0, inputType)).toBe(true); + } + ); + + test.each([1, 2, 5])("leaves inputType alone while %i rule(s) remain", (remaining) => { + expect(shouldResetInputTypeToText(TSurveyElementTypeEnum.OpenText, remaining, "number")).toBe(false); + }); + + test("is a no-op when inputType is already text", () => { + expect(shouldResetInputTypeToText(TSurveyElementTypeEnum.OpenText, 0, "text")).toBe(false); + }); + + test("is a no-op when the element has no inputType", () => { + expect(shouldResetInputTypeToText(TSurveyElementTypeEnum.OpenText, 0, undefined)).toBe(false); + }); + + test.each([TSurveyElementTypeEnum.Address, TSurveyElementTypeEnum.ContactInfo] as const)( + "does not apply to %s elements, which have no inputType to reset", + (elementType) => { + expect(shouldResetInputTypeToText(elementType, 0, "number")).toBe(false); + } + ); +}); + +describe("applyRuleDeletion", () => { + const rule = (id: string): TValidationRule => + ({ id, type: "minLength", params: { min: 1 } }) as TValidationRule; + + test("removes only the named rule", () => { + const result = applyRuleDeletion( + [rule("a"), rule("b"), rule("c")], + "b", + TSurveyElementTypeEnum.OpenText, + "number" + ); + expect(result.rules.map((r) => r.id)).toEqual(["a", "c"]); + }); + + test("deleting the last rule asks the caller to reset inputType", () => { + const result = applyRuleDeletion([rule("a")], "a", TSurveyElementTypeEnum.OpenText, "number"); + expect(result.rules).toEqual([]); + expect(result.resetInputTypeToText).toBe(true); + }); + + // Found in review: `app/lib/templates.ts` ships OpenText elements with inputType "email", + // longAnswer false and no `validation` key (139, 281, 2431), so zero rules with a non-text + // inputType is legitimate data. Resetting it here would flip longAnswer to true via + // open-element-form.tsx and turn a template email question into a textarea still placeholdered + // `example@email.com`. Long answer being disabled for those types is correct, not the ENG-2419 bug. + test.each(["email", "url", "phone"] as const)( + "deleting the last rule leaves inputType %s alone — zero rules is a shipped state for it", + (inputType) => { + const result = applyRuleDeletion([rule("a")], "a", TSurveyElementTypeEnum.OpenText, inputType); + expect(result.rules).toEqual([]); + expect(result.resetInputTypeToText).toBe(false); + } + ); + + // The disable path stays broader on purpose: `main` clears any non-text inputType when the section + // is toggled off, and this PR does not change that. Only the delete path is narrowed. + test.each(["email", "url", "phone"] as const)( + "disabling the section still clears inputType %s, matching main", + (inputType) => { + expect(shouldResetInputTypeToText(TSurveyElementTypeEnum.OpenText, 0, inputType)).toBe(true); + } + ); + + test("deleting one of several rules leaves inputType alone", () => { + const result = applyRuleDeletion([rule("a"), rule("b")], "a", TSurveyElementTypeEnum.OpenText, "number"); + expect(result.rules.map((r) => r.id)).toEqual(["b"]); + expect(result.resetInputTypeToText).toBe(false); + }); + + test("does not ask for a reset when inputType is already text", () => { + expect( + applyRuleDeletion([rule("a")], "a", TSurveyElementTypeEnum.OpenText, "text").resetInputTypeToText + ).toBe(false); + }); + + test("deleting an id that is not present is a no-op", () => { + const rules = [rule("a"), rule("b")]; + const result = applyRuleDeletion(rules, "missing", TSurveyElementTypeEnum.OpenText, "number"); + expect(result.rules.map((r) => r.id)).toEqual(["a", "b"]); + expect(result.resetInputTypeToText).toBe(false); + }); + + test("does not mutate the rules it was given", () => { + const rules = [rule("a"), rule("b")]; + applyRuleDeletion(rules, "a", TSurveyElementTypeEnum.OpenText, "number"); + expect(rules.map((r) => r.id)).toEqual(["a", "b"]); + }); +}); + +// `parseRuleValue` returning 0 is not the end of the story: `handleRuleValueChange` feeds it into +// `createRuleParams`, whose `createMinParams`/`createMaxParams` are `Number(value) || defaultValue` +// — and 0 is falsy, so for every rule type with a non-zero default the rejected paste lands on that +// default instead of on 0. Asserting the pair together so the value that actually reaches the rule +// is visible, rather than only what the parser hands back. +describe("parseRuleValue composed with createRuleParams", () => { + test.each([ + ["isLessThan", "1e3", { max: 100 }], + ["maxLength", "1e5", { max: 100 }], + ["maxValue", "1e5", { max: 100 }], + ["maxSelections", "1e5", { max: 3 }], + ["minSelections", "1e5", { min: 1 }], + ["minRanked", "1e5", { min: 1 }], + ["minRowsAnswered", "1e5", { min: 1 }], + ["minLength", "1e5", { min: 0 }], + ["minValue", "1e5", { min: 0 }], + ["isGreaterThan", "1e5", { min: 0 }], + ] as const)("%s rejects %j and stores %j", (ruleType, input, expected) => { + const parsed = parseRuleValue(ruleType, input, RULE_TYPE_CONFIG[ruleType]); + expect(parsed).toBe(0); + expect(createRuleParams(ruleType, parsed)).toEqual(expected); + }); + + // The guarantee ENG-2419 actually asked for: whatever the fallback resolves to, the pasted + // magnitude never survives into the rule. + test("the pasted magnitude never reaches the stored rule", () => { + const parsed = parseRuleValue("isLessThan", "1e5", RULE_TYPE_CONFIG.isLessThan); + expect(createRuleParams("isLessThan", parsed)).not.toEqual({ max: 100000 }); + }); + + // Pre-existing and independent of this PR — a literal 0 hits the same falsy fallback, so a + // user typing 0 into maxLength already gets 100 on main. Pinned so that fixing the fallback + // (see the ENG-2419 review thread) shows up here as a deliberate diff rather than a surprise. + test("a literal 0 is replaced by the rule default too (pre-existing behaviour)", () => { + expect(parseRuleValue("maxLength", "0", RULE_TYPE_CONFIG.maxLength)).toBe(0); + expect(createRuleParams("maxLength", 0)).toEqual({ max: 100 }); + }); }); diff --git a/apps/web/modules/survey/editor/lib/validation-rules-helpers.ts b/apps/web/modules/survey/editor/lib/validation-rules-helpers.ts index 79e7b06b8715..ecb6db84766a 100644 --- a/apps/web/modules/survey/editor/lib/validation-rules-helpers.ts +++ b/apps/web/modules/survey/editor/lib/validation-rules-helpers.ts @@ -1,11 +1,71 @@ -import { TSurveyElement } from "@formbricks/types/surveys/elements"; +import { + TSurveyElement, + TSurveyElementTypeEnum, + TSurveyOpenTextElementInputType, +} from "@formbricks/types/surveys/elements"; import { TAddressField, TContactInfoField, + TValidationRule, TValidationRuleType, } from "@formbricks/types/surveys/validation-rules"; import { RULE_TYPE_CONFIG } from "./validation-rules-config"; +/** + * The OpenText invariant: no validation rules means `inputType` must be `"text"`. + * + * The editor derives "validation is on" from the rule count, so any path that empties the rule list + * has to reset `inputType` too — otherwise the section reads as off while the element still carries + * `inputType: "number" | "email" | "url" | "phone"`, the Long answer toggle stays disabled, and the + * rendered input keeps enforcing browser-native format validation for respondents. + * + * `remainingRuleCount` is the count *after* the change, so deleting one of several rules leaves + * `inputType` alone. + */ +export const shouldResetInputTypeToText = ( + elementType: TSurveyElementTypeEnum, + remainingRuleCount: number, + inputType?: TSurveyOpenTextElementInputType +): boolean => + elementType === TSurveyElementTypeEnum.OpenText && + remainingRuleCount === 0 && + inputType !== undefined && + inputType !== "text"; + +/** + * The full state transition for deleting one validation rule: the remaining rules plus whether the + * caller must also reset `inputType` to `"text"`. + * + * Returning both together is the point. Deleting the last rule and switching the section off used to + * be two code paths and only the latter reset `inputType`, so the editor could leave an OpenText + * element with zero rules and `inputType: "number"`. Computing the new rule list without being + * handed that decision is now impossible. + * + * The deletion path deliberately resets only `number`, which is narrower than `handleDisable`. + * Toggling the section off is an explicit "stop validating this" action, and `main` already clears + * any non-text `inputType` there. Deleting one rule is not that action, and zero rules with a + * non-text `inputType` is shipped data rather than the bug state: `app/lib/templates.ts` builds + * OpenText elements with `inputType: "email"`, `longAnswer: false` and no `validation` key at all + * (lines 139, 281, 2431). Resetting those on a trash-icon click would flip `longAnswer` to true via + * `open-element-form.tsx`, turning a template email question into a textarea that still shows + * `example@email.com`. `number` is the one case ENG-2419 reports and the one where the leftover type + * is vestigial — set by adding a number rule, and keeping Long answer disabled with nothing left + * enforcing it. + */ +export const applyRuleDeletion = ( + rules: TValidationRule[], + ruleId: string, + elementType: TSurveyElementTypeEnum, + inputType?: TSurveyOpenTextElementInputType +): { rules: TValidationRule[]; resetInputTypeToText: boolean } => { + const remaining = rules.filter((rule) => rule.id !== ruleId); + return { + rules: remaining, + resetInputTypeToText: + inputType === "number" && shouldResetInputTypeToText(elementType, remaining.length, inputType), + }; +}; + // Field options for address elements export const getAddressFields = (t: (key: string) => string): { value: TAddressField; label: string }[] => [ { value: "addressLine1", label: t("workspace.surveys.edit.address_line_1") }, @@ -110,7 +170,21 @@ export const parseRuleValue = ( } if (config.valueType === "number") { - return Number(value) || 0; + // `onKeyDown` blocks the exponent keys but not paste, and `Number()` happily reads `1e5` as + // 100000. Accept only a plain decimal — optionally signed, since the numeric rule params are + // bare `z.number()` and a negative threshold is legitimate — and treat anything else as 0. + // The fractional part is one group starting with the literal `.` rather than `\.?\d*`: the + // latter lets a digit run be split between two `\d*`, so a long non-numeric paste backtracks + // quadratically (Sonar S8786). Same accepted strings, linear time. + if (!/^-?\d*(\.\d*)?$/.test(value.trim())) return 0; + // The regex admits an arbitrarily long digit run, and `Number()` turns anything past ~1e308 into + // `Infinity`, which is truthy — so `Number(value) || 0` let it through. `createMinParams` / + // `createMaxParams` are also truthy-guarded, so it reached the stored rule, and `JSON.stringify` + // writes `Infinity` as `null`: a 400-digit paste stored `{ max: null }` against a `z.number()` + // param. `NaN` (from a lone `-`, `.` or `-.`, which the regex also admits) was already caught by + // the truthy guard; requiring a finite number covers both without relying on falsiness. + const parsed = Number(value.trim()); + return Number.isFinite(parsed) ? parsed : 0; } return value; diff --git a/apps/web/modules/survey/multi-language-surveys/lib/utils.test.ts b/apps/web/modules/survey/multi-language-surveys/lib/utils.test.ts index ecdc1d0beddb..b6026219a121 100644 --- a/apps/web/modules/survey/multi-language-surveys/lib/utils.test.ts +++ b/apps/web/modules/survey/multi-language-surveys/lib/utils.test.ts @@ -10,6 +10,8 @@ const t = ((key: string, options?: Record) => { "common.headline": "Headline", "common.other_placeholder": "Other Placeholder", "workspace.surveys.edit.please_specify": "Please specify", + "workspace.surveys.edit.lower_label": "Lower label", + "workspace.surveys.edit.upper_label": "Upper label", }; return translations[key] ?? key; @@ -76,6 +78,65 @@ describe("multi-language survey utils", () => { ); }); + test("extracts the scale labels of csat and ces elements", () => { + const survey = createSurvey({ + blocks: [ + { + id: "block-1", + elements: [ + { + id: "csat", + type: TSurveyElementTypeEnum.CSAT, + headline: { default: "How satisfied are you?" }, + required: true, + scale: "smiley", + range: 5, + lowerLabel: { default: "Not satisfied at all" }, + upperLabel: { default: "Very satisfied" }, + }, + { + id: "ces", + type: TSurveyElementTypeEnum.CES, + headline: { default: "How easy was it?" }, + required: true, + scale: "number", + range: 5, + lowerLabel: { default: "Very difficult" }, + upperLabel: { default: "Very easy" }, + }, + ], + }, + ], + }); + + const strings = extractTranslatableStrings(survey, t); + + expect(strings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: "blocks.0.elements.0.lowerLabel", + fieldLabel: "Lower label", + value: { default: "Not satisfied at all" }, + }), + expect.objectContaining({ + path: "blocks.0.elements.0.upperLabel", + fieldLabel: "Upper label", + value: { default: "Very satisfied" }, + }), + expect.objectContaining({ + path: "blocks.0.elements.1.lowerLabel", + fieldLabel: "Lower label", + value: { default: "Very difficult" }, + }), + expect.objectContaining({ + path: "blocks.0.elements.1.upperLabel", + fieldLabel: "Upper label", + value: { default: "Very easy" }, + }), + ]) + ); + }); + test("extracts a missing other option placeholder for ranking elements", () => { const survey = createSurvey({ blocks: [ diff --git a/apps/web/modules/survey/multi-language-surveys/lib/utils.ts b/apps/web/modules/survey/multi-language-surveys/lib/utils.ts index be35880ac7fa..acfe1802ee4f 100644 --- a/apps/web/modules/survey/multi-language-surveys/lib/utils.ts +++ b/apps/web/modules/survey/multi-language-surveys/lib/utils.ts @@ -151,6 +151,8 @@ export const extractTranslatableStrings = (survey: TSurvey, t: TFunction): Trans } case TSurveyElementTypeEnum.NPS: case TSurveyElementTypeEnum.Rating: + case TSurveyElementTypeEnum.CSAT: + case TSurveyElementTypeEnum.CES: pushIfI18n(result, element, "lowerLabel", base, did, t("workspace.surveys.edit.lower_label"), eid); pushIfI18n(result, element, "upperLabel", base, did, t("workspace.surveys.edit.upper_label"), eid); break; diff --git a/apps/web/modules/ui/components/input-combo-box/index.tsx b/apps/web/modules/ui/components/input-combo-box/index.tsx index 9b2a29753859..980d9c0b36b5 100644 --- a/apps/web/modules/ui/components/input-combo-box/index.tsx +++ b/apps/web/modules/ui/components/input-combo-box/index.tsx @@ -303,9 +303,12 @@ export const InputCombobox: React.FC = ({ Array.isArray(localValue) ? localValue.includes(option.value as string) : localValue === option.value; return ( + // The height lives on this wrapper rather than on the trigger below: the wrapper clips its children + // (overflow-hidden), so a fixed-height trigger inside a shorter wrapper (comboboxClasses="h-9") gets + // cropped and its centered content sits low next to same-height controls in a filter row.
@@ -335,7 +338,7 @@ export const InputCombobox: React.FC = ({ aria-expanded={open} aria-disabled={disabled || undefined} className={cn( - "flex h-10 w-full min-w-0 cursor-pointer items-center overflow-hidden bg-white pr-2 text-sm", + "flex h-full w-full min-w-0 cursor-pointer items-center overflow-hidden bg-white pr-2 text-sm", { "w-10 shrink-0 justify-center pr-0": withInput && inputType !== "dropdown", "pointer-events-none": isClearing || disabled, diff --git a/packages/email/emails/auth/sso-recovery-factors-removed-email.tsx b/packages/email/emails/auth/sso-recovery-factors-removed-email.tsx new file mode 100644 index 000000000000..c988dcaa4fc8 --- /dev/null +++ b/packages/email/emails/auth/sso-recovery-factors-removed-email.tsx @@ -0,0 +1,66 @@ +import { Container, Heading, Text } from "@react-email/components"; +import { EmailButton } from "../../src/components/email-button"; +import { EmailFooter } from "../../src/components/email-footer"; +import { EmailTemplate } from "../../src/components/email-template"; +import { exampleData } from "../../src/lib/example-data"; +import { t as mockT } from "../../src/lib/mock-translate"; +import { TEmailTemplateLegalProps } from "../../src/types/email"; +import { TFunction } from "../../src/types/translations"; + +interface SsoRecoveryFactorsRemovedEmailProps extends TEmailTemplateLegalProps { + /** Whether a password was cleared — omitted from the list when the account had none. */ + readonly passwordRemoved: boolean; + /** Whether an enrolled second factor was removed. */ + readonly twoFactorRemoved: boolean; + /** Where to re-enrol: the account security settings. */ + readonly securitySettingsLink: string; + readonly t?: TFunction; +} + +/** + * Sent when SSO recovery stripped an account's local sign-in factors (ENG-2633). + * + * Recovery removes the password and any enrolled second factor from an account whose address was never + * proven, because an attacker who registered on someone else's address is held out by nothing else once + * recovery marks that address verified. The guard cannot tell that attacker from an owner who simply + * never clicked a verification link — and on a default self-hosted install, where verification blocks + * nothing, the owner is the likelier of the two. This mail is what stops the legitimate case being a + * silent security downgrade: it names what was removed and points at re-enrolment. + */ +export function SsoRecoveryFactorsRemovedEmail({ + passwordRemoved, + twoFactorRemoved, + securitySettingsLink, + t = mockT, + ...legalProps +}: Readonly): React.JSX.Element { + return ( + + + {t("emails.sso_recovery_factors_removed_email_heading")} + {t("emails.sso_recovery_factors_removed_email_text")} + {passwordRemoved ? ( + + {t("emails.sso_recovery_factors_removed_email_password")} + + ) : null} + {twoFactorRemoved ? ( + + {t("emails.sso_recovery_factors_removed_email_two_factor")} + + ) : null} + {t("emails.sso_recovery_factors_removed_email_sign_in_hint")} + + {t("emails.sso_recovery_factors_removed_email_did_not_expect")} + + + + ); +} + +export default function SsoRecoveryFactorsRemovedEmailPreview(): React.JSX.Element { + return ; +} diff --git a/packages/email/src/index.ts b/packages/email/src/index.ts index 9988b9d8736b..199cf3a956ff 100644 --- a/packages/email/src/index.ts +++ b/packages/email/src/index.ts @@ -3,6 +3,7 @@ export { ForgotPasswordEmail } from "../emails/auth/forgot-password-email"; export { DeleteAccountEmail } from "../emails/auth/delete-account-email"; export { NewEmailVerification } from "../emails/auth/new-email-verification"; export { PasswordResetNotifyEmail } from "../emails/auth/password-reset-notify-email"; +export { SsoRecoveryFactorsRemovedEmail } from "../emails/auth/sso-recovery-factors-removed-email"; export { InviteEmail } from "../emails/invite/invite-email"; export { InviteAcceptedEmail } from "../emails/invite/invite-accepted-email"; export { LinkSurveyEmail } from "../emails/survey/link-survey-email"; @@ -22,6 +23,7 @@ export { renderAccountDeletionEmail, renderNewEmailVerification, renderPasswordResetNotifyEmail, + renderSsoRecoveryFactorsRemovedEmail, renderInviteEmail, renderInviteAcceptedEmail, renderLinkSurveyEmail, diff --git a/packages/email/src/lib/example-data.ts b/packages/email/src/lib/example-data.ts index 4bca60199895..bca690886fbf 100644 --- a/packages/email/src/lib/example-data.ts +++ b/packages/email/src/lib/example-data.ts @@ -30,6 +30,12 @@ export const exampleData = { // No props needed }, + ssoRecoveryFactorsRemovedEmail: { + passwordRemoved: true, + twoFactorRemoved: true, + securitySettingsLink: "https://app.formbricks.com/account/settings/profile", + }, + inviteEmail: { inviteeName: "Jane Smith", inviterName: "John Doe", diff --git a/packages/email/src/lib/mock-translate.ts b/packages/email/src/lib/mock-translate.ts index 5e2143a23dfc..45adba486728 100644 --- a/packages/email/src/lib/mock-translate.ts +++ b/packages/email/src/lib/mock-translate.ts @@ -60,6 +60,18 @@ const translations: Record = { "emails.reject": "Reject", "emails.render_email_response_value_file_upload_response_link_not_included": "Link to uploaded file is not included for data privacy reasons", + "emails.sso_recovery_factors_removed_email_did_not_expect": + "If you did not just sign in with single sign-on, contact your administrator immediately.", + "emails.sso_recovery_factors_removed_email_heading": "Sign-in factors removed from your account", + "emails.sso_recovery_factors_removed_email_password": "Your password was removed.", + "emails.sso_recovery_factors_removed_email_review_security": "Review security settings", + "emails.sso_recovery_factors_removed_email_sign_in_hint": + "You can keep signing in with single sign-on. To use a password or two-factor authentication again, set them up in your security settings.", + "emails.sso_recovery_factors_removed_email_subject": + "Sign-in factors were removed from your Formbricks account", + "emails.sso_recovery_factors_removed_email_text": + "You just signed in with single sign-on for the first time. Because your email address had never been verified, we removed the sign-in factors that were set on the account before:", + "emails.sso_recovery_factors_removed_email_two_factor": "Two-factor authentication was removed.", "emails.response_data": "Response data", "emails.response_finished_email_subject": "A response for {surveyName} was completed ✅", "emails.schedule_your_meeting": "Schedule your meeting", diff --git a/packages/email/src/lib/render.test.ts b/packages/email/src/lib/render.test.ts index f619dea7d8ed..06646d6292d3 100644 --- a/packages/email/src/lib/render.test.ts +++ b/packages/email/src/lib/render.test.ts @@ -20,6 +20,7 @@ import { renderNewEmailVerification, renderPasswordResetNotifyEmail, renderResponseFinishedEmail, + renderSsoRecoveryFactorsRemovedEmail, renderVerificationEmail, } from "../index"; import { exampleData } from "./example-data"; @@ -58,6 +59,15 @@ const renderers: [string, () => Promise][] = [ () => renderNewEmailVerification({ ...exampleData.newEmailVerification, ...legal, t }), ], ["renderPasswordResetNotifyEmail", () => renderPasswordResetNotifyEmail({ ...legal, t })], + [ + "renderSsoRecoveryFactorsRemovedEmail", + () => + renderSsoRecoveryFactorsRemovedEmail({ + ...exampleData.ssoRecoveryFactorsRemovedEmail, + ...legal, + t, + }), + ], ["renderInviteEmail", () => renderInviteEmail({ ...exampleData.inviteEmail, ...legal, t })], [ "renderInviteAcceptedEmail", diff --git a/packages/email/src/lib/render.ts b/packages/email/src/lib/render.ts index b3e48a7a4ab0..6e410cdf95a9 100644 --- a/packages/email/src/lib/render.ts +++ b/packages/email/src/lib/render.ts @@ -3,6 +3,7 @@ import { DeleteAccountEmail } from "../../emails/auth/delete-account-email"; import { ForgotPasswordEmail } from "../../emails/auth/forgot-password-email"; import { NewEmailVerification } from "../../emails/auth/new-email-verification"; import { PasswordResetNotifyEmail } from "../../emails/auth/password-reset-notify-email"; +import { SsoRecoveryFactorsRemovedEmail } from "../../emails/auth/sso-recovery-factors-removed-email"; import { VerificationEmail } from "../../emails/auth/verification-email"; import { EmailCustomizationPreviewEmail } from "../../emails/general/email-customization-preview-email"; import { InviteAcceptedEmail } from "../../emails/invite/invite-accepted-email"; @@ -62,6 +63,17 @@ export async function renderPasswordResetNotifyEmail( return await render(PasswordResetNotifyEmail(props)); } +export async function renderSsoRecoveryFactorsRemovedEmail( + props: { + passwordRemoved: boolean; + twoFactorRemoved: boolean; + securitySettingsLink: string; + t: TFunction; + } & TEmailTemplateLegalProps +): Promise { + return await render(SsoRecoveryFactorsRemovedEmail(props)); +} + export async function renderInviteEmail( props: { inviteeName: string; diff --git a/packages/surveys/src/lib/logic.test.ts b/packages/surveys/src/lib/logic.test.ts index 90d4c10946fe..6cda777a8c67 100644 --- a/packages/surveys/src/lib/logic.test.ts +++ b/packages/surveys/src/lib/logic.test.ts @@ -1384,6 +1384,86 @@ describe("Survey Logic", () => { ).toBe(false); }); + test("evaluates doesNotEqual as the inverse of equals for single-selection answers", () => { + const multiSurvey: TJsWorkspaceStateSurvey = { + ...mockSurvey, + blocks: [ + ...mockSurvey.blocks, + { + id: "multiBlock", + name: "Multi Choice Block", + elements: [ + { + id: "multiQ", + type: TSurveyElementTypeEnum.MultipleChoiceMulti, + headline: { default: "Multiple Choice" }, + required: true, + choices: [ + { id: "opt1", label: { default: "Option 1" } }, + { id: "opt2", label: { default: "Option 2" } }, + ], + }, + ], + }, + ], + }; + // answer labels resolve to choice ids, so a single selection of "Option 1" becomes ["opt1"] + const singleSelectionData: TResponseData = { multiQ: ["Option 1"] }; + + const condition = (operator: "equals" | "doesNotEqual", value: string): TConditionGroup => ({ + id: "group1", + connector: "and", + conditions: [ + { + id: "condition1", + operator, + leftOperand: { type: "element", value: "multiQ" }, + rightOperand: { type: "static", value }, + }, + ], + }); + + // the selection matches the condition value: equals is true, so doesNotEqual must be false + expect( + evaluateLogic( + multiSurvey, + singleSelectionData, + mockVariablesData, + condition("equals", "opt1"), + "default" + ) + ).toBe(true); + expect( + evaluateLogic( + multiSurvey, + singleSelectionData, + mockVariablesData, + condition("doesNotEqual", "opt1"), + "default" + ) + ).toBe(false); + + // the selection does not match: equals is false, doesNotEqual is true + expect( + evaluateLogic( + multiSurvey, + singleSelectionData, + mockVariablesData, + condition("equals", "opt2"), + "default" + ) + ).toBe(false); + expect( + evaluateLogic( + multiSurvey, + singleSelectionData, + mockVariablesData, + condition("doesNotEqual", "opt2"), + "default" + ) + ).toBe(true); + }); + test("evaluates isEmpty and isNotEmpty operators", () => { // Test isEmpty const isEmptyCondition: TConditionGroup = { diff --git a/packages/surveys/src/lib/logic.ts b/packages/surveys/src/lib/logic.ts index 84b770158ede..4120694ad94a 100644 --- a/packages/surveys/src/lib/logic.ts +++ b/packages/surveys/src/lib/logic.ts @@ -329,13 +329,13 @@ const evaluateSingleCondition = ( } } - return ( - (Array.isArray(leftValue) && - leftValue.length === 1 && - typeof rightValue === "string" && - !leftValue.includes(rightValue)) || - leftValue !== rightValue - ); + // decide inside the guard: OR-ing past it would fall through to `leftValue !== rightValue`, + // which is always true for an array vs. a string (a matching single selection included) + if (Array.isArray(leftValue) && leftValue.length === 1 && typeof rightValue === "string") { + return !leftValue.includes(rightValue); + } + + return leftValue !== rightValue; case "contains": return String(leftValue).includes(String(rightValue)); case "doesNotContain": diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6ed7a0142e51..039d9a07bf7b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -228,6 +228,7 @@ overrides: socket.io-parser@4: 4.2.7 fast-uri@3: 3.1.5 deepmerge-ts@7: 8.0.1 + mysql2: 3.23.1 patchedDependencies: '@better-auth/oauth-provider@1.7.0': c902485a86bcc6e01946ff9b5339866f3dcd2bbf84369e1c10cc4608e8e2ebd4 @@ -356,7 +357,7 @@ importers: version: 1.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0) '@better-auth/oauth-provider': specifier: 1.7.0 - version: 1.7.0(patch_hash=c902485a86bcc6e01946ff9b5339866f3dcd2bbf84369e1c10cc4608e8e2ebd4)(ddb3752cb5b0f8e8c6d96ce106aeec89) + version: 1.7.0(patch_hash=c902485a86bcc6e01946ff9b5339866f3dcd2bbf84369e1c10cc4608e8e2ebd4)(f6fef49d58f0a327548e8d09829b876b) '@better-auth/utils': specifier: 0.4.2 version: 0.4.2 @@ -518,7 +519,7 @@ importers: version: 0.11.6(@modelcontextprotocol/sdk@1.26.0(zod@4.3.6))(@modelcontextprotocol/server@2.0.0)(posthog-node@5.28.11(rxjs@7.8.2)) '@prisma/client': specifier: 'catalog:' - version: 7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3) + version: 7.8.0(prisma@7.8.0(@types/node@25.4.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3) '@prisma/instrumentation': specifier: 7.8.0 version: 7.8.0(@opentelemetry/api@1.9.0) @@ -602,7 +603,7 @@ importers: version: 3.0.3 better-auth: specifier: 1.7.0 - version: 1.7.0(patch_hash=1e1eb950e3163ebedbeeb9823f5951a9b9b12cf542ec6b15784bd06ddfb1582e)(@opentelemetry/api@1.9.0)(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.8.0)(mongodb@7.1.0(@aws-sdk/credential-providers@3.1013.0)(socks@2.8.7))(mysql2@3.20.0(@types/node@25.4.0))(next@16.2.11(@babel/core@7.29.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(pg@8.20.0)(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.6) + version: 1.7.0(patch_hash=1e1eb950e3163ebedbeeb9823f5951a9b9b12cf542ec6b15784bd06ddfb1582e)(@opentelemetry/api@1.9.0)(@prisma/client@7.8.0(prisma@7.8.0(@types/node@25.4.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.8.0)(mongodb@7.1.0(@aws-sdk/credential-providers@3.1013.0)(socks@2.8.7))(mysql2@3.23.1(@types/node@25.4.0))(next@16.2.11(@babel/core@7.29.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(pg@8.20.0)(prisma@7.8.0(@types/node@25.4.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.6) boring-avatars: specifier: 2.0.4 version: 2.0.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -1070,7 +1071,7 @@ importers: version: 7.8.0 '@prisma/client': specifier: 'catalog:' - version: 7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3) + version: 7.8.0(prisma@7.8.0(@types/node@25.4.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3) bcryptjs: specifier: 'catalog:' version: 3.0.3 @@ -1113,10 +1114,10 @@ importers: version: 13.0.6 prisma: specifier: 7.8.0 - version: 7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + version: 7.8.0(@types/node@25.4.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3) prisma-json-types-generator: specifier: 4.1.1 - version: 4.1.1(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3))(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3) + version: 4.1.1(@prisma/client@7.8.0(prisma@7.8.0(@types/node@25.4.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3))(prisma@7.8.0(@types/node@25.4.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3) squawk-cli: specifier: 2.63.0 version: 2.63.0 @@ -7292,7 +7293,7 @@ packages: drizzle-kit: '>=0.31.4 || >=1.0.0-beta.1' drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0 mongodb: ^6.0.0 || ^7.0.0 - mysql2: ^3.0.0 + mysql2: 3.23.1 next: ^14.0.0 || ^15.0.0 || ^16.0.0 pg: ^8.0.0 prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 @@ -9928,12 +9929,8 @@ packages: muggle-string@0.4.1: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} - mysql2@3.15.3: - resolution: {integrity: sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==} - engines: {node: '>= 8.0'} - - mysql2@3.20.0: - resolution: {integrity: sha512-eCLUs7BNbgA6nf/MZXsaBO1SfGs0LtLVrJD3WeWq+jPLDWkSufTD+aGMwykfUVPdZnblaUK1a8G/P63cl9FkKg==} + mysql2@3.23.1: + resolution: {integrity: sha512-tTuRnC7qCet2IOfSNMYZ5SwXuBnfvBPAcIA28P0gtruXyZlU1LMxA6uha32kYypoFgyYklMqhLWwt4laYwXR/Q==} engines: {node: '>= 8.0'} peerDependencies: '@types/node': '>= 8' @@ -11190,9 +11187,6 @@ packages: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} - seq-queue@0.0.5: - resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==} - serve-static@2.2.1: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} @@ -11378,8 +11372,8 @@ packages: sprintf-js@1.1.3: resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} - sql-escaper@1.3.3: - resolution: {integrity: sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==} + sql-escaper@1.5.1: + resolution: {integrity: sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg==} engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'} sql-highlight@6.1.0: @@ -11389,10 +11383,6 @@ packages: sqlite3@5.1.7: resolution: {integrity: sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==} - sqlstring@2.3.3: - resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} - engines: {node: '>= 0.6'} - squawk-cli@2.63.0: resolution: {integrity: sha512-OR9UHzY3kSLoZCB4qk3iSHeEm9pwVccHSWnRggfvRKo/QAddkWJEyoXlIsz0gPAPpzsyk60WcaY4SkswZTXPVA==} hasBin: true @@ -11905,7 +11895,7 @@ packages: ioredis: ^5.0.4 mongodb: ^5.8.0 || ^6.0.0 mssql: ^9.1.1 || ^10.0.0 || ^11.0.0 || ^12.0.0 - mysql2: ^2.2.5 || ^3.0.1 + mysql2: 3.23.1 oracledb: ^6.3.0 || ^7.0.0 pg: ^8.5.1 pg-native: ^3.0.0 @@ -14087,23 +14077,23 @@ snapshots: optionalDependencies: mongodb: 7.1.0(@aws-sdk/credential-providers@3.1013.0)(socks@2.8.7) - '@better-auth/oauth-provider@1.7.0(patch_hash=c902485a86bcc6e01946ff9b5339866f3dcd2bbf84369e1c10cc4608e8e2ebd4)(ddb3752cb5b0f8e8c6d96ce106aeec89)': + '@better-auth/oauth-provider@1.7.0(patch_hash=c902485a86bcc6e01946ff9b5339866f3dcd2bbf84369e1c10cc4608e8e2ebd4)(f6fef49d58f0a327548e8d09829b876b)': dependencies: '@better-auth/core': 1.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 - better-auth: 1.7.0(patch_hash=1e1eb950e3163ebedbeeb9823f5951a9b9b12cf542ec6b15784bd06ddfb1582e)(@opentelemetry/api@1.9.0)(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.8.0)(mongodb@7.1.0(@aws-sdk/credential-providers@3.1013.0)(socks@2.8.7))(mysql2@3.20.0(@types/node@25.4.0))(next@16.2.11(@babel/core@7.29.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(pg@8.20.0)(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.6) + better-auth: 1.7.0(patch_hash=1e1eb950e3163ebedbeeb9823f5951a9b9b12cf542ec6b15784bd06ddfb1582e)(@opentelemetry/api@1.9.0)(@prisma/client@7.8.0(prisma@7.8.0(@types/node@25.4.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.8.0)(mongodb@7.1.0(@aws-sdk/credential-providers@3.1013.0)(socks@2.8.7))(mysql2@3.23.1(@types/node@25.4.0))(next@16.2.11(@babel/core@7.29.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(pg@8.20.0)(prisma@7.8.0(@types/node@25.4.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.6) better-call: 1.4.0(zod@4.3.6) jose: 6.2.9 zod: 4.3.6 - '@better-auth/prisma-adapter@1.7.0(@better-auth/core@1.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3))(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))': + '@better-auth/prisma-adapter@1.7.0(@better-auth/core@1.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@prisma/client@7.8.0(prisma@7.8.0(@types/node@25.4.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3))(prisma@7.8.0(@types/node@25.4.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))': dependencies: '@better-auth/core': 1.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0) '@better-auth/utils': 0.4.2 optionalDependencies: - '@prisma/client': 7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3) - prisma: 7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + '@prisma/client': 7.8.0(prisma@7.8.0(@types/node@25.4.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3) + prisma: 7.8.0(@types/node@25.4.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3) '@better-auth/telemetry@1.7.0(@better-auth/core@1.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': dependencies: @@ -14151,14 +14141,14 @@ snapshots: mixpanel: 0.20.0 mongodb: 7.1.0(@aws-sdk/credential-providers@3.1013.0)(socks@2.8.7) mssql: 12.2.1 - mysql2: 3.20.0(@types/node@25.4.0) + mysql2: 3.23.1(@types/node@25.4.0) node-forge: 1.4.0 openid-client: 6.8.2 pg: 8.20.0 redis: 4.7.1 reflect-metadata: 0.2.2 ripemd160: 2.0.3 - typeorm: 0.3.31(better-sqlite3@12.8.0)(ioredis@5.8.1)(mongodb@7.1.0(@aws-sdk/credential-providers@3.1013.0)(socks@2.8.7))(mssql@12.2.1)(mysql2@3.20.0(@types/node@25.4.0))(pg@8.20.0)(redis@4.7.1)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@25.4.0)(typescript@5.9.3)) + typeorm: 0.3.31(better-sqlite3@12.8.0)(ioredis@5.8.1)(mongodb@7.1.0(@aws-sdk/credential-providers@3.1013.0)(socks@2.8.7))(mssql@12.2.1)(mysql2@3.23.1(@types/node@25.4.0))(pg@8.20.0)(redis@4.7.1)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@25.4.0)(typescript@5.9.3)) transitivePeerDependencies: - '@google-cloud/spanner' - '@mongodb-js/zstd' @@ -16387,11 +16377,11 @@ snapshots: '@prisma/client-runtime-utils@7.8.0': {} - '@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3)': + '@prisma/client@7.8.0(prisma@7.8.0(@types/node@25.4.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3)': dependencies: '@prisma/client-runtime-utils': 7.8.0 optionalDependencies: - prisma: 7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + prisma: 7.8.0(@types/node@25.4.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3) typescript: 5.9.3 '@prisma/config@7.8.0(magicast@0.5.2)': @@ -19776,14 +19766,14 @@ snapshots: jsonpointer: 5.0.1 leven: 3.1.0 - better-auth@1.7.0(patch_hash=1e1eb950e3163ebedbeeb9823f5951a9b9b12cf542ec6b15784bd06ddfb1582e)(@opentelemetry/api@1.9.0)(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.8.0)(mongodb@7.1.0(@aws-sdk/credential-providers@3.1013.0)(socks@2.8.7))(mysql2@3.20.0(@types/node@25.4.0))(next@16.2.11(@babel/core@7.29.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(pg@8.20.0)(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.6): + better-auth@1.7.0(patch_hash=1e1eb950e3163ebedbeeb9823f5951a9b9b12cf542ec6b15784bd06ddfb1582e)(@opentelemetry/api@1.9.0)(@prisma/client@7.8.0(prisma@7.8.0(@types/node@25.4.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.8.0)(mongodb@7.1.0(@aws-sdk/credential-providers@3.1013.0)(socks@2.8.7))(mysql2@3.23.1(@types/node@25.4.0))(next@16.2.11(@babel/core@7.29.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(pg@8.20.0)(prisma@7.8.0(@types/node@25.4.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.6): dependencies: '@better-auth/core': 1.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.3.6))(jose@6.2.9)(kysely@0.29.2)(nanostores@1.3.0) '@better-auth/drizzle-adapter': 1.7.0(@better-auth/core@1.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2) '@better-auth/kysely-adapter': 1.7.0(@better-auth/core@1.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(kysely@0.29.2) '@better-auth/memory-adapter': 1.7.0(@better-auth/core@1.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2) '@better-auth/mongo-adapter': 1.7.0(@better-auth/core@1.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(mongodb@7.1.0(@aws-sdk/credential-providers@3.1013.0)(socks@2.8.7)) - '@better-auth/prisma-adapter': 1.7.0(@better-auth/core@1.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3))(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3)) + '@better-auth/prisma-adapter': 1.7.0(@better-auth/core@1.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@prisma/client@7.8.0(prisma@7.8.0(@types/node@25.4.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3))(prisma@7.8.0(@types/node@25.4.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3)) '@better-auth/telemetry': 1.7.0(@better-auth/core@1.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 @@ -19796,13 +19786,13 @@ snapshots: nanostores: 1.3.0 zod: 4.3.6 optionalDependencies: - '@prisma/client': 7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3) + '@prisma/client': 7.8.0(prisma@7.8.0(@types/node@25.4.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3) better-sqlite3: 12.8.0 mongodb: 7.1.0(@aws-sdk/credential-providers@3.1013.0)(socks@2.8.7) - mysql2: 3.20.0(@types/node@25.4.0) + mysql2: 3.23.1(@types/node@25.4.0) next: 16.2.11(@babel/core@7.29.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) pg: 8.20.0 - prisma: 7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + prisma: 7.8.0(@types/node@25.4.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) vitest: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) @@ -22635,19 +22625,7 @@ snapshots: muggle-string@0.4.1: {} - mysql2@3.15.3: - dependencies: - aws-ssl-profiles: 1.1.2 - denque: 2.1.0 - generate-function: 2.3.1 - iconv-lite: 0.7.2 - long: 5.3.2 - lru.min: 1.1.4 - named-placeholders: 1.1.6 - seq-queue: 0.0.5 - sqlstring: 2.3.3 - - mysql2@3.20.0(@types/node@25.4.0): + mysql2@3.23.1(@types/node@25.4.0): dependencies: '@types/node': 25.4.0 aws-ssl-profiles: 1.1.2 @@ -22657,7 +22635,7 @@ snapshots: long: 5.3.2 lru.min: 1.1.4 named-placeholders: 1.1.6 - sql-escaper: 1.3.3 + sql-escaper: 1.5.1 named-placeholders@1.1.6: dependencies: @@ -23332,28 +23310,29 @@ snapshots: ansi-styles: 5.2.0 react-is: 18.3.1 - prisma-json-types-generator@4.1.1(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3))(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3): + prisma-json-types-generator@4.1.1(@prisma/client@7.8.0(prisma@7.8.0(@types/node@25.4.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3))(prisma@7.8.0(@types/node@25.4.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3): dependencies: - '@prisma/client': 7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3) + '@prisma/client': 7.8.0(prisma@7.8.0(@types/node@25.4.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3) '@prisma/generator-helper': 7.8.0 - prisma: 7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + prisma: 7.8.0(@types/node@25.4.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3) semver: 7.8.0 try: 1.0.3 tslib: 2.8.1 typescript: 5.9.3 - prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3): + prisma@7.8.0(@types/node@25.4.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3): dependencies: '@prisma/config': 7.8.0(magicast@0.5.2) '@prisma/dev': 0.24.3(typescript@5.9.3) '@prisma/engines': 7.8.0 '@prisma/studio-core': 0.27.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - mysql2: 3.15.3 + mysql2: 3.23.1(@types/node@25.4.0) postgres: 3.4.7 optionalDependencies: better-sqlite3: 12.8.0 typescript: 5.9.3 transitivePeerDependencies: + - '@types/node' - '@types/react' - '@types/react-dom' - magicast @@ -24096,8 +24075,6 @@ snapshots: - supports-color optional: true - seq-queue@0.0.5: {} - serve-static@2.2.1: dependencies: encodeurl: 2.0.0 @@ -24362,7 +24339,7 @@ snapshots: sprintf-js@1.1.3: {} - sql-escaper@1.3.3: {} + sql-escaper@1.5.1: {} sql-highlight@6.1.0: {} @@ -24379,8 +24356,6 @@ snapshots: - supports-color optional: true - sqlstring@2.3.3: {} - squawk-cli@2.63.0: optionalDependencies: '@squawk-cli/darwin-arm64': 2.63.0 @@ -24902,7 +24877,7 @@ snapshots: typedarray@0.0.6: {} - typeorm@0.3.31(better-sqlite3@12.8.0)(ioredis@5.8.1)(mongodb@7.1.0(@aws-sdk/credential-providers@3.1013.0)(socks@2.8.7))(mssql@12.2.1)(mysql2@3.20.0(@types/node@25.4.0))(pg@8.20.0)(redis@4.7.1)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@25.4.0)(typescript@5.9.3)): + typeorm@0.3.31(better-sqlite3@12.8.0)(ioredis@5.8.1)(mongodb@7.1.0(@aws-sdk/credential-providers@3.1013.0)(socks@2.8.7))(mssql@12.2.1)(mysql2@3.23.1(@types/node@25.4.0))(pg@8.20.0)(redis@4.7.1)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@25.4.0)(typescript@5.9.3)): dependencies: '@sqltools/formatter': 1.2.5 ansis: 4.3.1 @@ -24924,7 +24899,7 @@ snapshots: ioredis: 5.8.1 mongodb: 7.1.0(@aws-sdk/credential-providers@3.1013.0)(socks@2.8.7) mssql: 12.2.1 - mysql2: 3.20.0(@types/node@25.4.0) + mysql2: 3.23.1(@types/node@25.4.0) pg: 8.20.0 redis: 4.7.1 sqlite3: 5.1.7 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1feff3c678d1..137cb723de4f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -269,6 +269,16 @@ overrides: # load-bearing (dev-only, Prisma CLI): apps/web > @better-auth/oauth-provider > better-auth > # @better-auth/prisma-adapter > @prisma/client > prisma > @prisma/config resolves 7.1.5. deepmerge-ts@7: 8.0.1 + # ENG-2825 / GHSA-3f6p-5ww8-9rcr: mysql2 <3.22.0 registers mysql_clear_password as a standard auth + # plugin without requiring TLS, so a rogue server (or MITM) can force a client to send credentials in + # plaintext via an AuthSwitchRequest; patched in 3.22.0. + # GHSA-rgwj-5xj2-c3m3: mysql2 <=3.23.0 has no bound on the decompressed size of a zlib-compressed + # protocol packet, so a malicious/compromised server can DoS a client via a decompression bomb; + # patched in 3.23.1. Surfaced by `pnpm audit` (no Dependabot/Linear issue open for it yet); folded in + # here since it is the same package and pin. Raises the floor to 3.23.1 to close both. + # load-bearing: prisma resolves 3.15.3 (dev-only Prisma CLI, mysql adapter) and better-auth / + # @boxyhq/saml-jackson / typeorm resolve 3.20.0 (runtime, optional mysql adapters). + mysql2: 3.23.1 # === Removed once upstream caught up (kept as a record of what no longer needs a pin) === # ajv@6, @babel/core, body-parser, engine.io@6, linkify-it, protobufjs@7 and uuid@11 # all resolve to the patched version on their own now. diff --git a/sonar-project.properties b/sonar-project.properties index ba47876c5559..b52e73ffd43d 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -46,4 +46,7 @@ sonar.javascript.node.maxspace=8192 # `pnpm test:integration`), not by the unit run Sonar reads — mirror vite.config.mts's exclusions. # Also drop the apps/web/integration test-harness sources and the one-time cutover migration scripts. sonar.coverage.exclusions=**/*.test.*,**/*.spec.*,**/*.tsx,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,**/route.tsx,**/types/**,**/types.ts,**/stories.*,**/*.mock.*,**/mocks/**,**/__mocks__/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/openapi/merge-client-endpoints.ts,**/playwright/**,**/Dockerfile,**/*.config.cjs,**/*.css,**/templates.ts,**/actions.ts,apps/web/modules/ui/components/icons/*,**/*.json,apps/web/vitestSetup.ts,packages/js-core/src/index.ts,apps/web/tailwind.config.js,apps/web/postcss.config.js,apps/web/next.config.mjs,apps/web/scripts/**,packages/js-core/vitest.setup.ts,**/*.mjs,apps/web/modules/auth/lib/mock-data.ts,**/cache.ts,apps/web/app/**/billing-confirmation/**,apps/web/modules/ee/billing/**,apps/web/modules/survey/multi-language-surveys/**,apps/web/modules/email/**,apps/web/modules/integrations/**,apps/web/modules/setup/**/intro/**,apps/web/modules/setup/**/signup/**,apps/web/modules/setup/**/layout.tsx,apps/web/modules/survey/follow-ups/**,apps/web/app/share/**,apps/web/modules/ee/contacts/[contactId]/**,apps/web/modules/ee/contacts/components/**,apps/web/modules/ee/two-factor-auth/**,apps/web/lib/slack/**,apps/web/lib/notion/**,apps/web/lib/googleSheet/**,apps/web/app/api/google-sheet/**,apps/web/app/api/billing/**,apps/web/lib/airtable/**,apps/web/app/api/v1/integrations/**,apps/web/lib/env.ts,apps/web/lib/env-client.ts,**/instrumentation-node.ts,**/cache/**,**/*.svg,apps/web/modules/ui/components/icons/**,apps/web/modules/ui/components/table/**,packages/survey-ui/**/*.stories.*,apps/web/integration/**,apps/web/modules/auth/lib/auth.ts,apps/web/modules/auth/lib/auth-client.ts,apps/web/modules/auth/lib/cutover/**,apps/web/modules/auth/lib/better-auth-email-verification.ts,apps/web/modules/ee/sso/lib/better-auth-recovery-signin.ts,apps/web/modules/account/lib/better-auth-account-deletion-request.ts -sonar.cpd.exclusions=apps/web/modules/ee/analysis/lib/schema-definition.ts,**/*.test.*,**/*.spec.*,**/*.tsx,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,**/route.tsx,**/types/**,**/types.ts,**/stories.*,**/*.mock.*,**/mocks/**,**/__mocks__/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/openapi/merge-client-endpoints.ts,**/playwright/**,**/Dockerfile,**/*.config.cjs,**/*.css,**/templates.ts,**/actions.ts,apps/web/modules/ui/components/icons/*,**/*.json,apps/web/vitestSetup.ts,apps/web/tailwind.config.js,apps/web/postcss.config.js,apps/web/next.config.mjs,apps/web/scripts/**,packages/js-core/vitest.setup.ts,packages/js-core/src/index.ts,**/*.mjs,apps/web/modules/auth/lib/mock-data.ts,**/cache.ts,apps/web/app/**/billing-confirmation/**,apps/web/modules/ee/billing/**,apps/web/modules/survey/multi-language-surveys/**,apps/web/modules/email/**,apps/web/modules/integrations/**,apps/web/modules/setup/**/intro/**,apps/web/modules/setup/**/signup/**,apps/web/modules/setup/**/layout.tsx,apps/web/modules/survey/follow-ups/**,apps/web/app/share/**,apps/web/modules/ee/contacts/[contactId]/**,apps/web/modules/ee/contacts/components/**,apps/web/modules/ee/two-factor-auth/**,apps/web/lib/slack/**,apps/web/lib/notion/**,apps/web/lib/googleSheet/**,apps/web/app/api/google-sheet/**,apps/web/app/api/billing/**,apps/web/lib/airtable/**,apps/web/app/api/v1/integrations/**,apps/web/lib/env.ts,**/instrumentation-node.ts,**/cache/**,**/*.svg,apps/web/modules/ui/components/icons/**,apps/web/modules/ui/components/table/**,packages/survey-ui/**/*.stories.* +# CPD: apps/web/lib/surveyLogic/utils.ts is the deliberate mirror of the survey runtime evaluator in +# packages/surveys/src/lib/logic.ts — every fix must land in both copies, so new-code duplication +# between them is expected until the evaluator is unified into a shared package. +sonar.cpd.exclusions=apps/web/modules/ee/analysis/lib/schema-definition.ts,apps/web/lib/surveyLogic/utils.ts,**/*.test.*,**/*.spec.*,**/*.tsx,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,**/route.tsx,**/types/**,**/types.ts,**/stories.*,**/*.mock.*,**/mocks/**,**/__mocks__/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/openapi/merge-client-endpoints.ts,**/playwright/**,**/Dockerfile,**/*.config.cjs,**/*.css,**/templates.ts,**/actions.ts,apps/web/modules/ui/components/icons/*,**/*.json,apps/web/vitestSetup.ts,apps/web/tailwind.config.js,apps/web/postcss.config.js,apps/web/next.config.mjs,apps/web/scripts/**,packages/js-core/vitest.setup.ts,packages/js-core/src/index.ts,**/*.mjs,apps/web/modules/auth/lib/mock-data.ts,**/cache.ts,apps/web/app/**/billing-confirmation/**,apps/web/modules/ee/billing/**,apps/web/modules/survey/multi-language-surveys/**,apps/web/modules/email/**,apps/web/modules/integrations/**,apps/web/modules/setup/**/intro/**,apps/web/modules/setup/**/signup/**,apps/web/modules/setup/**/layout.tsx,apps/web/modules/survey/follow-ups/**,apps/web/app/share/**,apps/web/modules/ee/contacts/[contactId]/**,apps/web/modules/ee/contacts/components/**,apps/web/modules/ee/two-factor-auth/**,apps/web/lib/slack/**,apps/web/lib/notion/**,apps/web/lib/googleSheet/**,apps/web/app/api/google-sheet/**,apps/web/app/api/billing/**,apps/web/lib/airtable/**,apps/web/app/api/v1/integrations/**,apps/web/lib/env.ts,**/instrumentation-node.ts,**/cache/**,**/*.svg,apps/web/modules/ui/components/icons/**,apps/web/modules/ui/components/table/**,packages/survey-ui/**/*.stories.*