add admin account recovery via setup secret
All checks were successful
deploy / deploy (push) Successful in 1m33s
All checks were successful
deploy / deploy (push) Successful in 1m33s
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 <noreply@anthropic.com>
This commit is contained in:
parent
194fec8240
commit
b0607621f3
@ -69,6 +69,13 @@ defmodule Berrypod.Accounts do
|
|||||||
Repo.exists?(User)
|
Repo.exists?(User)
|
||||||
end
|
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
|
## User registration
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|||||||
@ -29,9 +29,39 @@ defmodule BerrypodWeb.SetupController do
|
|||||||
end
|
end
|
||||||
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
|
defp login_failed(conn) do
|
||||||
conn
|
conn
|
||||||
|> put_flash(:error, "Login failed — please try again.")
|
|> put_flash(:error, "Login failed — please try again.")
|
||||||
|> redirect(to: ~p"/setup")
|
|> redirect(to: ~p"/setup")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp recover_login_failed(conn) do
|
||||||
|
conn
|
||||||
|
|> put_flash(:error, "Recovery login failed — please try again.")
|
||||||
|
|> redirect(to: ~p"/recover")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
defmodule BerrypodWeb.Auth.Login do
|
defmodule BerrypodWeb.Auth.Login do
|
||||||
use BerrypodWeb, :live_view
|
use BerrypodWeb, :live_view
|
||||||
|
|
||||||
alias Berrypod.Accounts
|
alias Berrypod.{Accounts, Mailer}
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
@ -38,6 +38,7 @@ defmodule BerrypodWeb.Auth.Login do
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<%= if @email_configured do %>
|
||||||
<.form
|
<.form
|
||||||
:let={f}
|
:let={f}
|
||||||
for={@form}
|
for={@form}
|
||||||
@ -60,6 +61,7 @@ defmodule BerrypodWeb.Auth.Login do
|
|||||||
</.form>
|
</.form>
|
||||||
|
|
||||||
<div class="admin-divider">or</div>
|
<div class="admin-divider">or</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<.form
|
<.form
|
||||||
:let={f}
|
:let={f}
|
||||||
@ -90,6 +92,13 @@ defmodule BerrypodWeb.Auth.Login do
|
|||||||
Log in only this time
|
Log in only this time
|
||||||
</.button>
|
</.button>
|
||||||
</.form>
|
</.form>
|
||||||
|
|
||||||
|
<p :if={!@email_configured} class="text-sm text-center text-base-content/60">
|
||||||
|
Locked out?
|
||||||
|
<.link navigate={~p"/recover"} class="font-semibold text-brand hover:underline">
|
||||||
|
Recover with setup secret
|
||||||
|
</.link>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
"""
|
"""
|
||||||
@ -104,7 +113,12 @@ defmodule BerrypodWeb.Auth.Login do
|
|||||||
form = to_form(%{"email" => email}, as: "user")
|
form = to_form(%{"email" => email}, as: "user")
|
||||||
|
|
||||||
{:ok,
|
{: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
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|||||||
118
lib/berrypod_web/live/setup/recover.ex
Normal file
118
lib/berrypod_web/live/setup/recover.ex
Normal file
@ -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"""
|
||||||
|
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||||
|
<div class="mx-auto max-w-sm flex flex-col gap-4">
|
||||||
|
<.header>
|
||||||
|
Account recovery
|
||||||
|
<:subtitle>
|
||||||
|
Reset your admin password using the setup secret from your server logs.
|
||||||
|
</:subtitle>
|
||||||
|
</.header>
|
||||||
|
|
||||||
|
<div class="admin-alert admin-alert-info">
|
||||||
|
<.icon name="hero-information-circle" class="size-6 shrink-0" />
|
||||||
|
<p>A recovery secret has been printed to your server logs.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<.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
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-base-content/60 -mt-2">Minimum 12 characters</p>
|
||||||
|
<.button variant="primary" class="w-full">
|
||||||
|
Reset password and log in
|
||||||
|
</.button>
|
||||||
|
</.form>
|
||||||
|
|
||||||
|
<p class="text-sm text-center text-base-content/60">
|
||||||
|
<.link navigate={~p"/users/log-in"} class="font-semibold text-brand hover:underline">
|
||||||
|
Back to login
|
||||||
|
</.link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Layouts.app>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -146,12 +146,14 @@ defmodule BerrypodWeb.Router do
|
|||||||
scope "/", BerrypodWeb do
|
scope "/", BerrypodWeb do
|
||||||
pipe_through [:browser]
|
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 "/setup/login/:token", SetupController, :login
|
||||||
|
get "/recover/login/:token", SetupController, :recover_login
|
||||||
|
|
||||||
live_session :setup,
|
live_session :setup,
|
||||||
on_mount: [{BerrypodWeb.UserAuth, :mount_current_scope}] do
|
on_mount: [{BerrypodWeb.UserAuth, :mount_current_scope}] do
|
||||||
live "/setup", Setup.Onboarding, :index
|
live "/setup", Setup.Onboarding, :index
|
||||||
|
live "/recover", Setup.Recover, :index
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
defmodule BerrypodWeb.Auth.LoginTest do
|
defmodule BerrypodWeb.Auth.LoginTest do
|
||||||
use BerrypodWeb.ConnCase
|
use BerrypodWeb.ConnCase, async: false
|
||||||
|
|
||||||
import Phoenix.LiveViewTest
|
import Phoenix.LiveViewTest
|
||||||
import Berrypod.AccountsFixtures
|
import Berrypod.AccountsFixtures
|
||||||
@ -75,6 +75,45 @@ defmodule BerrypodWeb.Auth.LoginTest do
|
|||||||
end
|
end
|
||||||
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
|
describe "login navigation" do
|
||||||
test "redirects to setup page when the setup link is clicked", %{conn: conn} do
|
test "redirects to setup page when the setup link is clicked", %{conn: conn} do
|
||||||
{:ok, lv, _html} = live(conn, ~p"/users/log-in")
|
{:ok, lv, _html} = live(conn, ~p"/users/log-in")
|
||||||
|
|||||||
108
test/berrypod_web/live/setup/recover_test.exs
Normal file
108
test/berrypod_web/live/setup/recover_test.exs
Normal file
@ -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
|
||||||
Loading…
Reference in New Issue
Block a user