From b0607621f31d823f15ac3201ad5a6880e78802c0 Mon Sep 17 00:00:00 2001 From: jamey Date: Sat, 21 Feb 2026 21:40:53 +0000 Subject: [PATCH] add admin account recovery via setup secret When email isn't configured, the login page now hides the magic link form and shows a recovery link. The /recover page logs the setup secret to server logs and lets the admin reset their password with it. Co-Authored-By: Claude Opus 4.6 --- lib/berrypod/accounts.ex | 7 ++ .../controllers/setup_controller.ex | 30 +++++ lib/berrypod_web/live/auth/login.ex | 60 +++++---- lib/berrypod_web/live/setup/recover.ex | 118 ++++++++++++++++++ lib/berrypod_web/router.ex | 4 +- test/berrypod_web/live/auth/login_test.exs | 41 +++++- test/berrypod_web/live/setup/recover_test.exs | 108 ++++++++++++++++ 7 files changed, 343 insertions(+), 25 deletions(-) create mode 100644 lib/berrypod_web/live/setup/recover.ex create mode 100644 test/berrypod_web/live/setup/recover_test.exs diff --git a/lib/berrypod/accounts.ex b/lib/berrypod/accounts.ex index 356ed55..af06383 100644 --- a/lib/berrypod/accounts.ex +++ b/lib/berrypod/accounts.ex @@ -69,6 +69,13 @@ defmodule Berrypod.Accounts do Repo.exists?(User) end + @doc """ + Returns the first (and typically only) admin user, or nil. + """ + def get_first_admin do + Repo.one(from u in User, limit: 1) + end + ## User registration @doc """ diff --git a/lib/berrypod_web/controllers/setup_controller.ex b/lib/berrypod_web/controllers/setup_controller.ex index 6f9439c..a1d084c 100644 --- a/lib/berrypod_web/controllers/setup_controller.ex +++ b/lib/berrypod_web/controllers/setup_controller.ex @@ -29,9 +29,39 @@ defmodule BerrypodWeb.SetupController do end end + @doc """ + Logs in after a successful password recovery. + + Same flow as setup login — validates the token, sets the session cookie, + then redirects to admin. + """ + def recover_login(conn, %{"token" => token}) do + if Accounts.get_user_by_magic_link_token(token) do + case Accounts.login_user_by_magic_link(token) do + {:ok, {user, tokens_to_disconnect}} -> + UserAuth.disconnect_sessions(tokens_to_disconnect) + + conn + |> put_session(:user_return_to, ~p"/admin") + |> UserAuth.log_in_user(user) + + _ -> + recover_login_failed(conn) + end + else + recover_login_failed(conn) + end + end + defp login_failed(conn) do conn |> put_flash(:error, "Login failed — please try again.") |> redirect(to: ~p"/setup") end + + defp recover_login_failed(conn) do + conn + |> put_flash(:error, "Recovery login failed — please try again.") + |> redirect(to: ~p"/recover") + end end diff --git a/lib/berrypod_web/live/auth/login.ex b/lib/berrypod_web/live/auth/login.ex index 41f822b..5c34a1e 100644 --- a/lib/berrypod_web/live/auth/login.ex +++ b/lib/berrypod_web/live/auth/login.ex @@ -1,7 +1,7 @@ defmodule BerrypodWeb.Auth.Login do use BerrypodWeb, :live_view - alias Berrypod.Accounts + alias Berrypod.{Accounts, Mailer} @impl true def render(assigns) do @@ -38,28 +38,30 @@ defmodule BerrypodWeb.Auth.Login do - <.form - :let={f} - for={@form} - id="login_form_magic" - action={~p"/users/log-in"} - phx-submit="submit_magic" - > - <.input - readonly={!!@current_scope} - field={f[:email]} - type="email" - label="Email" - autocomplete="email" - required - phx-mounted={JS.focus()} - /> - <.button variant="primary" class="w-full"> - Log in with email - - + <%= if @email_configured do %> + <.form + :let={f} + for={@form} + id="login_form_magic" + action={~p"/users/log-in"} + phx-submit="submit_magic" + > + <.input + readonly={!!@current_scope} + field={f[:email]} + type="email" + label="Email" + autocomplete="email" + required + phx-mounted={JS.focus()} + /> + <.button variant="primary" class="w-full"> + Log in with email + + -
or
+
or
+ <% end %> <.form :let={f} @@ -90,6 +92,13 @@ defmodule BerrypodWeb.Auth.Login do Log in only this time + +

+ Locked out? + <.link navigate={~p"/recover"} class="font-semibold text-brand hover:underline"> + Recover with setup secret + +

""" @@ -104,7 +113,12 @@ defmodule BerrypodWeb.Auth.Login do form = to_form(%{"email" => email}, as: "user") {:ok, - assign(socket, form: form, trigger_submit: false, registration_open: !Accounts.has_admin?())} + assign(socket, + form: form, + trigger_submit: false, + registration_open: !Accounts.has_admin?(), + email_configured: Mailer.email_configured?() + )} end @impl true diff --git a/lib/berrypod_web/live/setup/recover.ex b/lib/berrypod_web/live/setup/recover.ex new file mode 100644 index 0000000..4de8d14 --- /dev/null +++ b/lib/berrypod_web/live/setup/recover.ex @@ -0,0 +1,118 @@ +defmodule BerrypodWeb.Setup.Recover do + use BerrypodWeb, :live_view + + require Logger + + alias Berrypod.{Accounts, Setup} + + @impl true + def mount(_params, _session, socket) do + cond do + get_user(socket) -> + {:ok, push_navigate(socket, to: ~p"/admin")} + + not Accounts.has_admin?() -> + {:ok, push_navigate(socket, to: ~p"/setup")} + + true -> + Logger.warning("Account recovery requested. Setup secret: #{Setup.setup_secret()}") + + {:ok, + socket + |> assign(:page_title, "Account recovery") + |> assign(:require_secret, Application.get_env(:berrypod, :env, :dev) == :prod) + |> assign(:form, to_form(%{}, as: :recover))} + end + end + + defp get_user(socket) do + case socket.assigns do + %{current_scope: %{user: user}} when not is_nil(user) -> user + _ -> nil + end + end + + @impl true + def handle_event("recover", %{"recover" => params}, socket) do + secret = params["secret"] || "" + password = params["password"] || "" + + cond do + socket.assigns.require_secret and + not Plug.Crypto.secure_compare(secret, Setup.setup_secret()) -> + {:noreply, put_flash(socket, :error, "Wrong setup secret")} + + String.length(password) < 12 -> + {:noreply, put_flash(socket, :error, "Password must be at least 12 characters")} + + true -> + user = Accounts.get_first_admin() + + case Accounts.update_user_password(user, %{password: password}) do + {:ok, {_user, _tokens}} -> + token = Accounts.generate_login_token(user) + {:noreply, redirect(socket, to: ~p"/recover/login/#{token}")} + + {:error, changeset} -> + message = + changeset + |> Ecto.Changeset.traverse_errors(fn {msg, _} -> msg end) + |> Enum.flat_map(fn {_field, msgs} -> msgs end) + |> Enum.join(", ") + + {:noreply, put_flash(socket, :error, message)} + end + end + end + + @impl true + def render(assigns) do + ~H""" + +
+ <.header> + Account recovery + <:subtitle> + Reset your admin password using the setup secret from your server logs. + + + +
+ <.icon name="hero-information-circle" class="size-6 shrink-0" /> +

A recovery secret has been printed to your server logs.

+
+ + <.form for={@form} phx-submit="recover"> + <.input + :if={@require_secret} + name="recover[secret]" + value="" + type="password" + label="Setup secret" + autocomplete="off" + required + /> + <.input + name="recover[password]" + value="" + type="password" + label="New password" + autocomplete="new-password" + required + /> +

Minimum 12 characters

+ <.button variant="primary" class="w-full"> + Reset password and log in + + + +

+ <.link navigate={~p"/users/log-in"} class="font-semibold text-brand hover:underline"> + Back to login + +

+
+
+ """ + end +end diff --git a/lib/berrypod_web/router.ex b/lib/berrypod_web/router.ex index 3d93dde..fa1926b 100644 --- a/lib/berrypod_web/router.ex +++ b/lib/berrypod_web/router.ex @@ -146,12 +146,14 @@ defmodule BerrypodWeb.Router do scope "/", BerrypodWeb do pipe_through [:browser] - # Token-based auto-login after setup account creation + # Token-based auto-login after setup/recovery get "/setup/login/:token", SetupController, :login + get "/recover/login/:token", SetupController, :recover_login live_session :setup, on_mount: [{BerrypodWeb.UserAuth, :mount_current_scope}] do live "/setup", Setup.Onboarding, :index + live "/recover", Setup.Recover, :index end end diff --git a/test/berrypod_web/live/auth/login_test.exs b/test/berrypod_web/live/auth/login_test.exs index be6d557..905d898 100644 --- a/test/berrypod_web/live/auth/login_test.exs +++ b/test/berrypod_web/live/auth/login_test.exs @@ -1,5 +1,5 @@ defmodule BerrypodWeb.Auth.LoginTest do - use BerrypodWeb.ConnCase + use BerrypodWeb.ConnCase, async: false import Phoenix.LiveViewTest import Berrypod.AccountsFixtures @@ -75,6 +75,45 @@ defmodule BerrypodWeb.Auth.LoginTest do end end + describe "email not configured" do + setup do + original = Application.get_env(:berrypod, Berrypod.Mailer) + Application.put_env(:berrypod, Berrypod.Mailer, adapter: Swoosh.Adapters.Local) + on_exit(fn -> Application.put_env(:berrypod, Berrypod.Mailer, original) end) + :ok + end + + test "hides magic link form and shows recovery link", %{conn: conn} do + _user = user_fixture() + {:ok, _lv, html} = live(conn, ~p"/users/log-in") + + refute html =~ "Log in with email" + assert html =~ "Locked out?" + assert html =~ "Recover with setup secret" + end + end + + describe "email configured" do + setup do + original = Application.get_env(:berrypod, Berrypod.Mailer) + + Application.put_env(:berrypod, Berrypod.Mailer, + adapter: Swoosh.Adapters.Postmark, + api_key: "test" + ) + + on_exit(fn -> Application.put_env(:berrypod, Berrypod.Mailer, original) end) + :ok + end + + test "shows magic link form and hides recovery link", %{conn: conn} do + {:ok, _lv, html} = live(conn, ~p"/users/log-in") + + assert html =~ "Log in with email" + refute html =~ "Locked out?" + end + end + describe "login navigation" do test "redirects to setup page when the setup link is clicked", %{conn: conn} do {:ok, lv, _html} = live(conn, ~p"/users/log-in") diff --git a/test/berrypod_web/live/setup/recover_test.exs b/test/berrypod_web/live/setup/recover_test.exs new file mode 100644 index 0000000..297bb85 --- /dev/null +++ b/test/berrypod_web/live/setup/recover_test.exs @@ -0,0 +1,108 @@ +defmodule BerrypodWeb.Setup.RecoverTest do + use BerrypodWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + import Berrypod.AccountsFixtures + import ExUnit.CaptureLog + + alias Berrypod.Setup + + describe "when no admin exists" do + test "redirects to setup", %{conn: conn} do + {:ok, _view, html} = + conn + |> live(~p"/recover") + |> follow_redirect(conn, ~p"/setup") + + assert html =~ "Set up your shop" + end + end + + describe "when admin exists" do + setup do + user = user_fixture() + %{user: user} + end + + test "renders recovery page", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/recover") + + assert html =~ "Account recovery" + assert html =~ "recovery secret has been printed" + assert html =~ "New password" + end + + test "logs setup secret on mount", %{conn: conn} do + log = + capture_log(fn -> + {:ok, _view, _html} = live(conn, ~p"/recover") + end) + + assert log =~ "Account recovery requested" + assert log =~ Setup.setup_secret() + end + + test "rejects short password", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/recover") + + html = + view + |> form("form", %{recover: %{password: "short"}}) + |> render_submit() + + assert html =~ "at least 12 characters" + end + + test "resets password and redirects to login", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/recover") + + result = + view + |> form("form", %{recover: %{password: "new_password_123"}}) + |> render_submit() + + assert {:error, {:redirect, %{to: "/recover/login/" <> _token}}} = result + end + end + + describe "when admin exists (prod mode)" do + setup do + original = Application.get_env(:berrypod, :env) + Application.put_env(:berrypod, :env, :prod) + user = user_fixture() + on_exit(fn -> Application.put_env(:berrypod, :env, original) end) + %{user: user} + end + + test "shows secret field in prod", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/recover") + assert html =~ "Setup secret" + end + + test "rejects wrong secret", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/recover") + + html = + view + |> form("form", %{recover: %{secret: "wrong_secret", password: "a_valid_password_123"}}) + |> render_submit() + + assert html =~ "Wrong setup secret" + end + end + + describe "when already logged in" do + setup %{conn: conn} do + user = user_fixture() + conn = log_in_user(conn, user) + %{conn: conn, user: user} + end + + test "redirects to admin", %{conn: conn} do + {:ok, _view, _html} = + conn + |> live(~p"/recover") + |> follow_redirect(conn, ~p"/admin") + end + end +end