From 7c72cca2719368abfbcdf708310c585cd53f3e37 Mon Sep 17 00:00:00 2001 From: Uku Taht Date: Tue, 21 Jul 2026 22:25:48 +0300 Subject: [PATCH 1/9] feat: replace hCaptcha with Friendly Captcha Switch the registration and password-reset bot protection from hCaptcha to Friendly Captcha v2 for its privacy-first, adaptive (Smart mode) challenge model that lets legitimate users pass without interaction. - Verify solutions server-side against the global siteverify endpoint using X-API-Key header auth and a JSON body. - Configure via FRIENDLY_CAPTCHA_SITEKEY and FRIENDLY_CAPTCHA_API_KEY. - Gate the form submit button until the widget completes (re-disabling on error/expire), as recommended by Friendly Captcha. --- config/.env.test | 4 +- config/runtime.exs | 10 +- lib/plausible_web/captcha.ex | 12 +- lib/plausible_web/components/layout.ex | 6 +- .../controllers/auth_controller.ex | 2 +- lib/plausible_web/live/register_form.ex | 200 ++++++++++-------- .../password_reset_request_form.html.heex | 112 ++++++---- test/plausible_web/captcha_test.exs | 18 +- 8 files changed, 209 insertions(+), 155 deletions(-) diff --git a/config/.env.test b/config/.env.test index 42a1e8552634..354c0bffdb91 100644 --- a/config/.env.test +++ b/config/.env.test @@ -9,8 +9,8 @@ ENVIRONMENT=test MAILER_ADAPTER=Bamboo.TestAdapter ENABLE_EMAIL_VERIFICATION=true SELFHOST=false -HCAPTCHA_SITEKEY=test -HCAPTCHA_SECRET=scottiger +FRIENDLY_CAPTCHA_SITEKEY=test +FRIENDLY_CAPTCHA_API_KEY=scottiger IP_GEOLOCATION_DB=test/priv/GeoLite2-City-Test.mmdb SITE_DEFAULT_INGEST_THRESHOLD=1000000 GOOGLE_CLIENT_ID=fake_client_id diff --git a/config/runtime.exs b/config/runtime.exs index 3992ddcdf9ef..efa38debcf0d 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -312,8 +312,8 @@ if disable_registration not in [true, false, :invite_only] do raise "DISABLE_REGISTRATION must be one of `true`, `false`, or `invite_only`. See https://github.com/plausible/community-edition/wiki/configuration#disable_registration" end -hcaptcha_sitekey = get_var_from_path_or_env(config_dir, "HCAPTCHA_SITEKEY") -hcaptcha_secret = get_var_from_path_or_env(config_dir, "HCAPTCHA_SECRET") +friendly_captcha_sitekey = get_var_from_path_or_env(config_dir, "FRIENDLY_CAPTCHA_SITEKEY") +friendly_captcha_api_key = get_var_from_path_or_env(config_dir, "FRIENDLY_CAPTCHA_API_KEY") custom_script_name = config_dir @@ -899,9 +899,9 @@ else queues: queues end -config :plausible, :hcaptcha, - sitekey: hcaptcha_sitekey, - secret: hcaptcha_secret +config :plausible, :friendly_captcha, + sitekey: friendly_captcha_sitekey, + api_key: friendly_captcha_api_key nolt_sso_secret = get_var_from_path_or_env(config_dir, "NOLT_SSO_SECRET") config :joken, default_signer: nolt_sso_secret diff --git a/lib/plausible_web/captcha.ex b/lib/plausible_web/captcha.ex index aff593d88eba..084b2a5ee308 100644 --- a/lib/plausible_web/captcha.ex +++ b/lib/plausible_web/captcha.ex @@ -1,14 +1,14 @@ defmodule PlausibleWeb.Captcha do alias Plausible.HTTPClient - @verify_endpoint "https://hcaptcha.com/siteverify" + @verify_endpoint "https://global.frcapi.com/api/v2/captcha/siteverify" def enabled? do is_binary(sitekey()) end def sitekey() do - Application.get_env(:plausible, :hcaptcha, [])[:sitekey] + Application.get_env(:plausible, :friendly_captcha, [])[:sitekey] end def verify(token) do @@ -16,10 +16,10 @@ defmodule PlausibleWeb.Captcha do res = HTTPClient.impl().post( @verify_endpoint, - [{"content-type", "application/x-www-form-urlencoded"}], + [{"content-type", "application/json"}, {"x-api-key", api_key()}], %{ response: token, - secret: secret() + sitekey: sitekey() } ) @@ -35,7 +35,7 @@ defmodule PlausibleWeb.Captcha do end end - defp secret() do - Application.get_env(:plausible, :hcaptcha, [])[:secret] + defp api_key() do + Application.get_env(:plausible, :friendly_captcha, [])[:api_key] end end diff --git a/lib/plausible_web/components/layout.ex b/lib/plausible_web/components/layout.ex index 9fc63ab11c5b..287b37d30f2e 100644 --- a/lib/plausible_web/components/layout.ex +++ b/lib/plausible_web/components/layout.ex @@ -55,16 +55,16 @@ defmodule PlausibleWeb.Components.Layout do function reapplyTheme() { var darkMediaPref = window.matchMedia('(prefers-color-scheme: dark)').matches; var htmlRef = document.querySelector('html'); - var hcaptchaRefs = Array.from(document.getElementsByClassName('h-captcha')); + var captchaRefs = Array.from(document.getElementsByClassName('frc-captcha')); var isDark = themePref === 'dark' || (themePref === 'system' && darkMediaPref); if (isDark) { htmlRef.classList.add('dark') - hcaptchaRefs.forEach(function(ref) { ref.dataset.theme = "dark"; }); + captchaRefs.forEach(function(ref) { ref.dataset.theme = "dark"; }); } else { htmlRef.classList.remove('dark'); - hcaptchaRefs.forEach(function(ref) { ref.dataset.theme = "light"; }); + captchaRefs.forEach(function(ref) { ref.dataset.theme = "light"; }); } } diff --git a/lib/plausible_web/controllers/auth_controller.ex b/lib/plausible_web/controllers/auth_controller.ex index 13ae747473d4..f142a04d6b20 100644 --- a/lib/plausible_web/controllers/auth_controller.ex +++ b/lib/plausible_web/controllers/auth_controller.ex @@ -177,7 +177,7 @@ defmodule PlausibleWeb.AuthController do end def password_reset_request(conn, %{"email" => email} = params) do - if PlausibleWeb.Captcha.verify(params["h-captcha-response"]) do + if PlausibleWeb.Captcha.verify(params["frc-captcha-response"]) do case Auth.lookup(email) do {:ok, _user} -> token = Auth.Token.sign_password_reset(email) diff --git a/lib/plausible_web/live/register_form.ex b/lib/plausible_web/live/register_form.ex index 3ee6c1333470..2baf7075130c 100644 --- a/lib/plausible_web/live/register_form.ex +++ b/lib/plausible_web/live/register_form.ex @@ -84,99 +84,119 @@ defmodule PlausibleWeb.Live.RegisterForm do def render(assigns) do ~H""" <.auth_container> - <.form - :let={f} - for={@form} - id="register-form" - class="flex flex-col gap-y-6" - action={Routes.auth_path(@socket, :login)} - onsubmit={form_submit_event(@invitation)} - phx-hook="Metrics" - phx-change="validate" - phx-submit="register" - phx-trigger-action={@trigger_submit} - > - - - - <%= if @invitation do %> - <.email_input field={f[:email]} for_invitation={true} /> - <.name_input field={f[:name]} /> - <% else %> - <.name_input field={f[:name]} /> - <.email_input field={f[:email]} for_invitation={false} /> - <% end %> - -
- -
- <.password_input_with_strength - field={f[:password]} - strength={@password_strength} - phx-debounce={200} - mt?={false} - /> -
- <.password_length_hint minimum={12} field={f[:password]} hide_when_used?={true} /> -
- - <%= if PlausibleWeb.Captcha.enabled?() do %> -
-
+ <.form + :let={f} + for={@form} + id="register-form" + class="flex flex-col gap-y-6" + action={Routes.auth_path(@socket, :login)} + onsubmit={form_submit_event(@invitation)} + phx-hook="Metrics" + phx-change="validate" + phx-submit="register" + phx-trigger-action={@trigger_submit} + > + + + + <%= if @invitation do %> + <.email_input field={f[:email]} for_invitation={true} /> + <.name_input field={f[:name]} /> + <% else %> + <.name_input field={f[:name]} /> + <.email_input field={f[:email]} for_invitation={false} /> + <% end %> + +
+ +
+ <.password_input_with_strength + field={f[:password]} + strength={@password_strength} + phx-debounce={200} + mt?={false} + /> +
+ <.password_length_hint minimum={12} field={f[:password]} hide_when_used?={true} /> +
+ + <%= if PlausibleWeb.Captcha.enabled?() do %> +
+
+
+

+ {@captcha_error} +

+ +
-

+ +

+ <% submit_text = + if ce?() or @invitation do + "Create my account" + else + "Start my free trial" + end %> + <.button + id="register" + disabled={@disable_submit or PlausibleWeb.Captcha.enabled?()} + x-bind:disabled={"!captchaDone || #{@disable_submit}"} + type="submit" + class="w-full" + mt?={false} > - {@captcha_error} + {submit_text} + + +

+ Already have an account? + <.styled_link href="/login"> + Sign in +

-
- <% end %> - -
- <% submit_text = - if ce?() or @invitation do - "Create my account" - else - "Start my free trial" - end %> - <.button id="register" disabled={@disable_submit} type="submit" class="w-full" mt?={false}> - {submit_text} - - -

- Already have an account? - <.styled_link href="/login"> - Sign in - -

-
- + +
""" end @@ -259,7 +279,7 @@ defmodule PlausibleWeb.Live.RegisterForm do %{assigns: %{invitation: %{} = invitation}} = socket ) do if not PlausibleWeb.Captcha.enabled?() or - PlausibleWeb.Captcha.verify(params["h-captcha-response"]) do + PlausibleWeb.Captcha.verify(params["frc-captcha-response"]) do user = params["user"] |> Map.put("email", invitation.email) @@ -275,7 +295,7 @@ defmodule PlausibleWeb.Live.RegisterForm do def handle_event("register", %{"user" => _} = params, socket) do if not PlausibleWeb.Captcha.enabled?() or - PlausibleWeb.Captcha.verify(params["h-captcha-response"]) do + PlausibleWeb.Captcha.verify(params["frc-captcha-response"]) do user = Auth.User.new(params["user"]) add_user(socket, user) diff --git a/lib/plausible_web/templates/auth/password_reset_request_form.html.heex b/lib/plausible_web/templates/auth/password_reset_request_form.html.heex index 806bfebdb6a1..1376d70be663 100644 --- a/lib/plausible_web/templates/auth/password_reset_request_form.html.heex +++ b/lib/plausible_web/templates/auth/password_reset_request_form.html.heex @@ -1,46 +1,78 @@ <.auth_container> - <.form - :let={f} - for={@conn} - action={Routes.auth_path(@conn, :password_reset_request)} - class="flex flex-col gap-y-6" - > -
- -
- <.input - type="email" - autocomplete="username" - placeholder="example@email.com" - field={f[:email]} - mt?={false} - autofocus="autofocus" - /> -
-

- {@conn.assigns[:error]} -

-
- - <%= if PlausibleWeb.Captcha.enabled?() do %> -
-
-

- {@captcha_error} +

+ <.form + :let={f} + for={@conn} + action={Routes.auth_path(@conn, :password_reset_request)} + class="flex flex-col gap-y-6" + > +
+ +
+ <.input + type="email" + autocomplete="username" + placeholder="example@email.com" + field={f[:email]} + mt?={false} + autofocus="autofocus" + /> +
+

+ {@conn.assigns[:error]}

-
- <% end %> -
- <.button class="w-full" type="submit" mt?={false}>Send reset link + <%= if PlausibleWeb.Captcha.enabled?() do %> +
+
+
+

+ {@captcha_error} +

+ + +
+ <% end %> -

- <.styled_link href="/login">Back to sign in -

-
- +
+ <.button + class="w-full" + type="submit" + mt?={false} + disabled={PlausibleWeb.Captcha.enabled?()} + x-bind:disabled="!captchaDone" + > + Send reset link + + +

+ <.styled_link href="/login">Back to sign in +

+
+ +
diff --git a/test/plausible_web/captcha_test.exs b/test/plausible_web/captcha_test.exs index 7308283bf3a4..141e7510dd47 100644 --- a/test/plausible_web/captcha_test.exs +++ b/test/plausible_web/captcha_test.exs @@ -7,16 +7,18 @@ defmodule PlausibleWeb.CaptchaTest do alias PlausibleWeb.Captcha describe "mocked payloads" do - @failure Jason.decode!(~s/{"success":false,"error-codes":["invalid-input-response"]}/) + @failure Jason.decode!( + ~s/{"success":false,"error":{"error_code":"response_invalid","detail":"the response was invalid"}}/ + ) @success Jason.decode!(~s/{"success":true}/) test "returns false for non-success response" do expect( Plausible.HTTPClient.Mock, :post, - fn "https://hcaptcha.com/siteverify", - [{"content-type", "application/x-www-form-urlencoded"}], - %{response: "bad", secret: "scottiger"} -> + fn "https://global.frcapi.com/api/v2/captcha/siteverify", + [{"content-type", "application/json"}, {"x-api-key", "scottiger"}], + %{response: "bad", sitekey: "test"} -> {:ok, %Finch.Response{ status: 200, @@ -33,9 +35,9 @@ defmodule PlausibleWeb.CaptchaTest do expect( Plausible.HTTPClient.Mock, :post, - fn "https://hcaptcha.com/siteverify", - [{"content-type", "application/x-www-form-urlencoded"}], - %{response: "good", secret: "scottiger"} -> + fn "https://global.frcapi.com/api/v2/captcha/siteverify", + [{"content-type", "application/json"}, {"x-api-key", "scottiger"}], + %{response: "good", sitekey: "test"} -> {:ok, %Finch.Response{ status: 200, @@ -50,7 +52,7 @@ defmodule PlausibleWeb.CaptchaTest do end describe "with patched application env" do - setup_patch_env(:hcaptcha, sitekey: nil) + setup_patch_env(:friendly_captcha, sitekey: nil) test "returns true when disabled" do assert Captcha.verify("disabled") From 3f353e5373d46ab37ec3961100239f0592e8cec5 Mon Sep 17 00:00:00 2001 From: Uku Taht Date: Thu, 23 Jul 2026 15:32:09 +0300 Subject: [PATCH 2/9] Use inivisible captcha --- lib/plausible_web/live/register_form.ex | 244 ++++++++++-------- .../password_reset_request_form.html.heex | 178 ++++++++----- 2 files changed, 244 insertions(+), 178 deletions(-) diff --git a/lib/plausible_web/live/register_form.ex b/lib/plausible_web/live/register_form.ex index 2baf7075130c..6cd08c5cf128 100644 --- a/lib/plausible_web/live/register_form.ex +++ b/lib/plausible_web/live/register_form.ex @@ -84,119 +84,149 @@ defmodule PlausibleWeb.Live.RegisterForm do def render(assigns) do ~H""" <.auth_container> -
- <.form - :let={f} - for={@form} - id="register-form" - class="flex flex-col gap-y-6" - action={Routes.auth_path(@socket, :login)} - onsubmit={form_submit_event(@invitation)} - phx-hook="Metrics" - phx-change="validate" - phx-submit="register" - phx-trigger-action={@trigger_submit} - > - - - - <%= if @invitation do %> - <.email_input field={f[:email]} for_invitation={true} /> - <.name_input field={f[:name]} /> - <% else %> - <.name_input field={f[:name]} /> - <.email_input field={f[:email]} for_invitation={false} /> - <% end %> - -
- -
- <.password_input_with_strength - field={f[:password]} - strength={@password_strength} - phx-debounce={200} - mt?={false} - /> -
- <.password_length_hint minimum={12} field={f[:password]} hide_when_used?={true} /> -
+ <.form + :let={f} + for={@form} + id="register-form" + class="flex flex-col gap-y-6" + action={Routes.auth_path(@socket, :login)} + onsubmit={form_submit_event(@invitation)} + phx-hook="Metrics" + phx-change="validate" + phx-submit="register" + phx-trigger-action={@trigger_submit} + > + + - <%= if PlausibleWeb.Captcha.enabled?() do %> -
-
-
-

- {@captcha_error} -

- - -
- <% end %> - -
- <% submit_text = - if ce?() or @invitation do - "Create my account" - else - "Start my free trial" - end %> - <.button - id="register" - disabled={@disable_submit or PlausibleWeb.Captcha.enabled?()} - x-bind:disabled={"!captchaDone || #{@disable_submit}"} - type="submit" - class="w-full" + <%= if @invitation do %> + <.email_input field={f[:email]} for_invitation={true} /> + <.name_input field={f[:name]} /> + <% else %> + <.name_input field={f[:name]} /> + <.email_input field={f[:email]} for_invitation={false} /> + <% end %> + +
+ +
+ <.password_input_with_strength + field={f[:password]} + strength={@password_strength} + phx-debounce={200} mt?={false} + /> +
+ <.password_length_hint minimum={12} field={f[:password]} hide_when_used?={true} /> +
+ + <%= if PlausibleWeb.Captcha.enabled?() do %> +
+ +

+ {@captcha_error} +

+

+ This site is protected by + <.styled_link href="https://friendlycaptcha.com" new_tab={true}> + Friendly Captcha

+ + +
- -
+ <% end %> + +
+ <% submit_text = + if ce?() or @invitation do + "Create my account" + else + "Start my free trial" + end %> + <.button + id="register" + type="submit" + class="w-full" + mt?={false} + data-disable-submit={to_string(@disable_submit)} + x-data={"{ captchaReady: #{not PlausibleWeb.Captcha.enabled?()} }"} + x-on:frc-captcha-ready.window="captchaReady = true" + x-on:frc-captcha-reset.window="captchaReady = false" + x-bind:disabled="!captchaReady || $el.dataset.disableSubmit === 'true'" + > + {submit_text} + + +

+ Already have an account? + <.styled_link href="/login"> + Sign in + +

+
+ """ end diff --git a/lib/plausible_web/templates/auth/password_reset_request_form.html.heex b/lib/plausible_web/templates/auth/password_reset_request_form.html.heex index 1376d70be663..4e129a9af183 100644 --- a/lib/plausible_web/templates/auth/password_reset_request_form.html.heex +++ b/lib/plausible_web/templates/auth/password_reset_request_form.html.heex @@ -1,78 +1,114 @@ <.auth_container> -
- <.form - :let={f} - for={@conn} - action={Routes.auth_path(@conn, :password_reset_request)} - class="flex flex-col gap-y-6" - > -
- -
- <.input - type="email" - autocomplete="username" - placeholder="example@email.com" - field={f[:email]} - mt?={false} - autofocus="autofocus" - /> -
-

- {@conn.assigns[:error]} -

+ <.form + :let={f} + for={@conn} + action={Routes.auth_path(@conn, :password_reset_request)} + class="flex flex-col gap-y-6" + > +
+ +
+ <.input + type="email" + autocomplete="username" + placeholder="example@email.com" + field={f[:email]} + mt?={false} + autofocus="autofocus" + />
+

+ {@conn.assigns[:error]} +

+
- <%= if PlausibleWeb.Captcha.enabled?() do %> -
-
-
-

- {@captcha_error} -

- - -
- <% end %> - -
- <.button - class="w-full" - type="submit" - mt?={false} - disabled={PlausibleWeb.Captcha.enabled?()} - x-bind:disabled="!captchaDone" + <%= if PlausibleWeb.Captcha.enabled?() do %> +
+ +

+ {@captcha_error} +

+

+ This site is protected by + <.styled_link href="https://friendlycaptcha.com" new_tab={true}> + Friendly Captcha + + and its + <.styled_link href="https://friendlycaptcha.com/privacy/user-protection/" new_tab={true}> + Privacy Policy + + and + <.styled_link href="https://friendlycaptcha.com/legal/terms/" new_tab={true}> + Terms of Use + + apply.

+ + +
- -
+ <% end %> + +
+ <.button + class="w-full" + type="submit" + mt?={false} + x-data={"{ captchaReady: #{not PlausibleWeb.Captcha.enabled?()} }"} + x-on:frc-captcha-ready.window="captchaReady = true" + x-on:frc-captcha-reset.window="captchaReady = false" + x-bind:disabled="!captchaReady" + > + Send reset link + + +

+ <.styled_link href="/login">Back to sign in +

+
+ From d9887938dc7b1ea1dcd8e39c560143154d251a4f Mon Sep 17 00:00:00 2001 From: Uku Taht Date: Fri, 24 Jul 2026 12:37:55 +0300 Subject: [PATCH 3/9] feat: sync captcha widget theme with app light/dark mode --- lib/plausible_web/components/layout.ex | 3 --- lib/plausible_web/live/register_form.ex | 14 ++++++++++++++ .../auth/password_reset_request_form.html.heex | 14 ++++++++++++++ 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/lib/plausible_web/components/layout.ex b/lib/plausible_web/components/layout.ex index 287b37d30f2e..068433cb2c92 100644 --- a/lib/plausible_web/components/layout.ex +++ b/lib/plausible_web/components/layout.ex @@ -55,16 +55,13 @@ defmodule PlausibleWeb.Components.Layout do function reapplyTheme() { var darkMediaPref = window.matchMedia('(prefers-color-scheme: dark)').matches; var htmlRef = document.querySelector('html'); - var captchaRefs = Array.from(document.getElementsByClassName('frc-captcha')); var isDark = themePref === 'dark' || (themePref === 'system' && darkMediaPref); if (isDark) { htmlRef.classList.add('dark') - captchaRefs.forEach(function(ref) { ref.dataset.theme = "dark"; }); } else { htmlRef.classList.remove('dark'); - captchaRefs.forEach(function(ref) { ref.dataset.theme = "light"; }); } } diff --git a/lib/plausible_web/live/register_form.ex b/lib/plausible_web/live/register_form.ex index 6cd08c5cf128..fea2cc3e7fe5 100644 --- a/lib/plausible_web/live/register_form.ex +++ b/lib/plausible_web/live/register_form.ex @@ -172,6 +172,20 @@ defmodule PlausibleWeb.Live.RegisterForm do var SHOW_AFTER_LONG_WAIT_MS = 5000; var el = document.getElementById("frc-captcha-placeholder"); if (!el) return; + + // Match the widget to the app's resolved light/dark theme. This runs + // before the (deferred) SDK initializes, so the widget picks up the + // right theme from the start, and the observer keeps it in sync. + function applyTheme() { + el.dataset.theme = + document.documentElement.classList.contains("dark") ? "dark" : "light"; + } + applyTheme(); + new MutationObserver(applyTheme).observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"] + }); + function show() { el.classList.remove("hidden"); } var timeout; // Friendly Captcha carries the event payload on `e.detail` (not `e`). diff --git a/lib/plausible_web/templates/auth/password_reset_request_form.html.heex b/lib/plausible_web/templates/auth/password_reset_request_form.html.heex index 4e129a9af183..d3467bb8c35b 100644 --- a/lib/plausible_web/templates/auth/password_reset_request_form.html.heex +++ b/lib/plausible_web/templates/auth/password_reset_request_form.html.heex @@ -70,6 +70,20 @@ var SHOW_AFTER_LONG_WAIT_MS = 5000; var el = document.getElementById("frc-captcha-placeholder"); if (!el) return; + + // Match the widget to the app's resolved light/dark theme. This runs + // before the (deferred) SDK initializes, so the widget picks up the + // right theme from the start, and the observer keeps it in sync. + function applyTheme() { + el.dataset.theme = + document.documentElement.classList.contains("dark") ? "dark" : "light"; + } + applyTheme(); + new MutationObserver(applyTheme).observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"] + }); + function show() { el.classList.remove("hidden"); } var timeout; // Friendly Captcha carries the event payload on `e.detail` (not `e`). From fa1d8112b036fb6db5e26251597d05bf3b73b0fa Mon Sep 17 00:00:00 2001 From: Uku Taht Date: Fri, 24 Jul 2026 12:48:51 +0300 Subject: [PATCH 4/9] refactor: extract shared Friendly Captcha widget component --- lib/plausible_web/components/captcha.ex | 100 ++++++++++++++++++ lib/plausible_web/live/register_form.ex | 92 ++-------------- .../password_reset_request_form.html.heex | 99 ++++------------- 3 files changed, 130 insertions(+), 161 deletions(-) create mode 100644 lib/plausible_web/components/captcha.ex diff --git a/lib/plausible_web/components/captcha.ex b/lib/plausible_web/components/captcha.ex new file mode 100644 index 000000000000..cab76a1b1e67 --- /dev/null +++ b/lib/plausible_web/components/captcha.ex @@ -0,0 +1,100 @@ +defmodule PlausibleWeb.Components.Captcha do + @moduledoc """ + Friendly Captcha widget shared between the registration and password-reset forms. + + Renders the (invisible) widget placeholder, the SDK script tags, and the reveal + script that: + + * matches the widget to the app's resolved light/dark theme, + * reveals the widget only when the user must interact (or on error/slow solve), + * dispatches `frc-captcha-ready` / `frc-captcha-reset` window events so the + submit button can gate on a valid solution. + + Pass `live?={true}` from a LiveView so the widget and scripts carry + `phx-update="ignore"` and survive DOM patching. + """ + use Phoenix.Component, global_prefixes: ~w(x-) + + attr :live?, :boolean, default: false + attr :error, :string, default: nil + slot :attribution, required: true + + def widget(assigns) do + ~H""" +
+ +

+ {@error} +

+ {render_slot(@attribution)} + + + +
+ """ + end +end diff --git a/lib/plausible_web/live/register_form.ex b/lib/plausible_web/live/register_form.ex index fea2cc3e7fe5..c00b30445c2f 100644 --- a/lib/plausible_web/live/register_form.ex +++ b/lib/plausible_web/live/register_form.ex @@ -131,85 +131,16 @@ defmodule PlausibleWeb.Live.RegisterForm do
<%= if PlausibleWeb.Captcha.enabled?() do %> -
- -

- {@captcha_error} -

-

- This site is protected by - <.styled_link href="https://friendlycaptcha.com" new_tab={true}> - Friendly Captcha - -

- - - -
+ + <:attribution> +

+ This site is protected by + <.styled_link href="https://friendlycaptcha.com" new_tab={true}> + Friendly Captcha + +

+ +
<% end %>
@@ -224,11 +155,10 @@ defmodule PlausibleWeb.Live.RegisterForm do type="submit" class="w-full" mt?={false} - data-disable-submit={to_string(@disable_submit)} x-data={"{ captchaReady: #{not PlausibleWeb.Captcha.enabled?()} }"} x-on:frc-captcha-ready.window="captchaReady = true" x-on:frc-captcha-reset.window="captchaReady = false" - x-bind:disabled="!captchaReady || $el.dataset.disableSubmit === 'true'" + x-bind:disabled={"!captchaReady || #{@disable_submit}"} > {submit_text} diff --git a/lib/plausible_web/templates/auth/password_reset_request_form.html.heex b/lib/plausible_web/templates/auth/password_reset_request_form.html.heex index d3467bb8c35b..832282d34ac0 100644 --- a/lib/plausible_web/templates/auth/password_reset_request_form.html.heex +++ b/lib/plausible_web/templates/auth/password_reset_request_form.html.heex @@ -25,86 +25,25 @@
<%= if PlausibleWeb.Captcha.enabled?() do %> -
- -

- {@captcha_error} -

-

- This site is protected by - <.styled_link href="https://friendlycaptcha.com" new_tab={true}> - Friendly Captcha - - and its - <.styled_link href="https://friendlycaptcha.com/privacy/user-protection/" new_tab={true}> - Privacy Policy - - and - <.styled_link href="https://friendlycaptcha.com/legal/terms/" new_tab={true}> - Terms of Use - - apply. -

- - - -
+ + <:attribution> +

+ This site is protected by + <.styled_link href="https://friendlycaptcha.com" new_tab={true}> + Friendly Captcha + + and its + <.styled_link href="https://friendlycaptcha.com/privacy/user-protection/" new_tab={true}> + Privacy Policy + + and + <.styled_link href="https://friendlycaptcha.com/legal/terms/" new_tab={true}> + Terms of Use + + apply. +

+ +
<% end %>
From 5b162fd1fa5b29f9238b1a09ba390d0a73e903d5 Mon Sep 17 00:00:00 2001 From: Uku Taht Date: Fri, 24 Jul 2026 13:13:27 +0300 Subject: [PATCH 5/9] refactor: inline captcha attribution into shared component Drop the per-form attribution slot and render a single shared attribution inside the widget component, simplifying both call sites. --- lib/plausible_web/components/captcha.ex | 8 ++++++-- lib/plausible_web/live/register_form.ex | 11 +--------- .../password_reset_request_form.html.heex | 20 +------------------ 3 files changed, 8 insertions(+), 31 deletions(-) diff --git a/lib/plausible_web/components/captcha.ex b/lib/plausible_web/components/captcha.ex index cab76a1b1e67..53645637760d 100644 --- a/lib/plausible_web/components/captcha.ex +++ b/lib/plausible_web/components/captcha.ex @@ -17,7 +17,6 @@ defmodule PlausibleWeb.Components.Captcha do attr :live?, :boolean, default: false attr :error, :string, default: nil - slot :attribution, required: true def widget(assigns) do ~H""" @@ -33,7 +32,12 @@ defmodule PlausibleWeb.Components.Captcha do

{@error}

- {render_slot(@attribution)} +

+ This site is protected by + + Friendly Captcha + +