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