separate account settings from shop settings
All checks were successful
deploy / deploy (push) Successful in 3m28s

- Create dedicated /admin/account page for user account management
- Move email, password, and 2FA settings from /admin/settings
- Add Account link to top of admin sidebar navigation
- Add TOTP-based two-factor authentication with NimbleTOTP
- Add TOTP verification LiveView for login flow
- Add AccountController for TOTP session management
- Remove Advanced section from settings (duplicated in dev tools)
- Remove user email from sidebar footer (replaced by Account link)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-03-08 18:42:29 +00:00
parent 0c2d4ac406
commit 32cc425458
21 changed files with 1396 additions and 308 deletions

View File

@@ -466,4 +466,104 @@ defmodule Berrypod.AccountsTest do
refute inspect(%User{password: "123456"}) =~ "password: \"123456\""
end
end
describe "TOTP 2FA" do
setup do
%{user: user_fixture()}
end
test "generate_totp_secret/1 returns a secret and URI", %{user: user} do
{secret, uri} = Accounts.generate_totp_secret(user)
assert is_binary(secret)
assert byte_size(secret) == 20
assert String.starts_with?(uri, "otpauth://totp/Berrypod:")
assert uri =~ user.email
end
test "totp_uri/2 regenerates the same URI format", %{user: user} do
{secret, original_uri} = Accounts.generate_totp_secret(user)
regenerated_uri = Accounts.totp_uri(user, secret)
assert original_uri == regenerated_uri
end
test "enable_totp/3 enables TOTP with valid code", %{user: user} do
{secret, _uri} = Accounts.generate_totp_secret(user)
code = NimbleTOTP.verification_code(secret)
assert {:ok, updated_user, backup_codes} = Accounts.enable_totp(user, secret, code)
assert updated_user.totp_enabled_at
assert updated_user.totp_secret_encrypted
assert updated_user.totp_backup_codes_encrypted
assert length(backup_codes) == 8
assert Enum.all?(backup_codes, &(String.length(&1) == 8))
end
test "enable_totp/3 fails with invalid code", %{user: user} do
{secret, _uri} = Accounts.generate_totp_secret(user)
assert {:error, :invalid_code} = Accounts.enable_totp(user, secret, "000000")
end
test "totp_enabled?/1 returns true when TOTP is enabled", %{user: user} do
refute Accounts.totp_enabled?(user)
{secret, _uri} = Accounts.generate_totp_secret(user)
code = NimbleTOTP.verification_code(secret)
{:ok, updated_user, _backup_codes} = Accounts.enable_totp(user, secret, code)
assert Accounts.totp_enabled?(updated_user)
end
test "verify_totp/2 accepts valid TOTP code", %{user: user} do
{secret, _uri} = Accounts.generate_totp_secret(user)
code = NimbleTOTP.verification_code(secret)
{:ok, user_with_totp, _backup_codes} = Accounts.enable_totp(user, secret, code)
# Generate a new valid code for verification
new_code = NimbleTOTP.verification_code(secret)
assert :ok = Accounts.verify_totp(user_with_totp, new_code)
end
test "verify_totp/2 rejects invalid TOTP code", %{user: user} do
{secret, _uri} = Accounts.generate_totp_secret(user)
code = NimbleTOTP.verification_code(secret)
{:ok, user_with_totp, _backup_codes} = Accounts.enable_totp(user, secret, code)
assert :error = Accounts.verify_totp(user_with_totp, "000000")
end
test "verify_totp/2 accepts valid backup code and consumes it", %{user: user} do
{secret, _uri} = Accounts.generate_totp_secret(user)
code = NimbleTOTP.verification_code(secret)
{:ok, user_with_totp, backup_codes} = Accounts.enable_totp(user, secret, code)
[first_backup | _rest] = backup_codes
# First use should succeed
assert :ok = Accounts.verify_totp(user_with_totp, first_backup)
# Reload user to get updated backup codes
updated_user = Accounts.get_user!(user.id)
# Second use of same code should fail
assert :error = Accounts.verify_totp(updated_user, first_backup)
end
test "disable_totp/1 removes TOTP configuration", %{user: user} do
{secret, _uri} = Accounts.generate_totp_secret(user)
code = NimbleTOTP.verification_code(secret)
{:ok, user_with_totp, _backup_codes} = Accounts.enable_totp(user, secret, code)
assert Accounts.totp_enabled?(user_with_totp)
{:ok, disabled_user} = Accounts.disable_totp(user_with_totp)
refute Accounts.totp_enabled?(disabled_user)
refute disabled_user.totp_secret_encrypted
refute disabled_user.totp_backup_codes_encrypted
refute disabled_user.totp_enabled_at
end
end
end

View File

@@ -0,0 +1,101 @@
defmodule Berrypod.RateLimitTest do
use ExUnit.Case, async: true
alias Berrypod.RateLimit
describe "check_login/1" do
test "allows requests within limit" do
ip = {192, 168, 1, unique_integer()}
for _i <- 1..5 do
assert :ok = RateLimit.check_login(ip)
end
end
test "blocks requests exceeding limit" do
ip = {192, 168, 2, unique_integer()}
for _i <- 1..5 do
assert :ok = RateLimit.check_login(ip)
end
assert {:error, retry_after} = RateLimit.check_login(ip)
assert is_integer(retry_after)
assert retry_after > 0
end
test "handles string IP addresses" do
ip = "10.0.0.#{unique_integer()}"
assert :ok = RateLimit.check_login(ip)
end
end
describe "check_magic_link/1" do
test "allows requests within limit" do
email = "test#{unique_integer()}@example.com"
for _i <- 1..3 do
assert :ok = RateLimit.check_magic_link(email)
end
end
test "blocks requests exceeding limit" do
email = "blocked#{unique_integer()}@example.com"
for _i <- 1..3 do
assert :ok = RateLimit.check_magic_link(email)
end
assert {:error, retry_after} = RateLimit.check_magic_link(email)
assert is_integer(retry_after)
assert retry_after > 0
end
end
describe "check_newsletter/1" do
test "allows requests within limit" do
ip = {172, 16, 1, unique_integer()}
for _i <- 1..10 do
assert :ok = RateLimit.check_newsletter(ip)
end
end
test "blocks requests exceeding limit" do
ip = {172, 16, 2, unique_integer()}
for _i <- 1..10 do
assert :ok = RateLimit.check_newsletter(ip)
end
assert {:error, retry_after} = RateLimit.check_newsletter(ip)
assert is_integer(retry_after)
end
end
describe "check_api/1" do
test "allows requests within limit" do
ip = {10, 10, 1, unique_integer()}
for _i <- 1..60 do
assert :ok = RateLimit.check_api(ip)
end
end
test "blocks requests exceeding limit" do
ip = {10, 10, 2, unique_integer()}
for _i <- 1..60 do
assert :ok = RateLimit.check_api(ip)
end
assert {:error, retry_after} = RateLimit.check_api(ip)
assert is_integer(retry_after)
end
end
defp unique_integer do
System.unique_integer([:positive]) |> rem(256)
end
end

View File

@@ -0,0 +1,123 @@
defmodule BerrypodWeb.Admin.AccountTest do
use BerrypodWeb.ConnCase, async: false
import Phoenix.LiveViewTest
import Berrypod.AccountsFixtures
alias Berrypod.Accounts
setup do
user = user_fixture()
%{user: user}
end
describe "unauthenticated" do
test "redirects to login", %{conn: conn} do
{:error, redirect} = live(conn, ~p"/admin/account")
assert {:redirect, %{to: path}} = redirect
assert path == ~p"/users/log-in"
end
end
describe "account page" do
setup %{conn: conn, user: user} do
conn = log_in_user(conn, user)
%{conn: conn, user: user}
end
test "renders email and password forms", %{conn: conn, user: user} do
{:ok, view, html} = live(conn, ~p"/admin/account")
assert html =~ "Account"
assert html =~ user.email
assert has_element?(view, "#email_form")
assert has_element?(view, "#password_form")
end
test "validates email change", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/account")
result =
view
|> element("#email_form")
|> render_change(%{"user" => %{"email" => "with spaces"}})
assert result =~ "must have the @ sign and no spaces"
end
test "submits email change", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/account")
result =
view
|> form("#email_form", %{"user" => %{"email" => unique_user_email()}})
|> render_submit()
assert result =~ "A link to confirm your email"
end
test "validates password", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/account")
result =
view
|> element("#password_form")
|> render_change(%{
"user" => %{
"password" => "short",
"password_confirmation" => "mismatch"
}
})
assert result =~ "should be at least 12 character(s)"
end
test "submits valid password change", %{conn: conn, user: user} do
new_password = valid_user_password()
{:ok, view, _html} = live(conn, ~p"/admin/account")
form =
form(view, "#password_form", %{
"user" => %{
"email" => user.email,
"password" => new_password,
"password_confirmation" => new_password
}
})
render_submit(form)
new_password_conn = follow_trigger_action(form, conn)
assert redirected_to(new_password_conn) == ~p"/admin/account"
assert Accounts.get_user_by_email_and_password(user.email, new_password)
end
end
describe "two-factor authentication" do
setup %{conn: conn, user: user} do
conn = log_in_user(conn, user)
%{conn: conn, user: user}
end
test "shows 2FA section", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin/account")
assert html =~ "Two-factor authentication"
assert html =~ "Off"
assert html =~ "Enable 2FA"
end
test "shows enabled state when TOTP is enabled", %{conn: conn, user: user} do
# Enable TOTP for the user
secret = NimbleTOTP.secret()
code = NimbleTOTP.verification_code(secret)
{:ok, _user, _codes} = Accounts.enable_totp(user, secret, code)
{:ok, _view, html} = live(conn, ~p"/admin/account")
assert html =~ "Two-factor authentication"
assert html =~ "Enabled"
assert html =~ "Disable 2FA"
end
end
end

View File

@@ -37,12 +37,6 @@ defmodule BerrypodWeb.Admin.LayoutTest do
refute has_element?(view, ~s(a.active[href="/admin/orders"]))
end
test "shows user email in sidebar", %{conn: conn, user: user} do
{:ok, _view, html} = live(conn, ~p"/admin/orders")
assert html =~ user.email
end
test "shows shop and log out links", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/orders")

View File

@@ -5,7 +5,6 @@ defmodule BerrypodWeb.Admin.SettingsTest do
import Berrypod.AccountsFixtures
import Berrypod.ProductsFixtures
alias Berrypod.Accounts
alias Berrypod.Settings
setup do
@@ -166,80 +165,6 @@ defmodule BerrypodWeb.Admin.SettingsTest do
end
end
describe "account section" do
setup %{conn: conn, user: user} do
conn = log_in_user(conn, user)
%{conn: conn, user: user}
end
test "renders email and password forms", %{conn: conn, user: user} do
{:ok, view, html} = live(conn, ~p"/admin/settings")
assert html =~ "Account"
assert html =~ user.email
assert has_element?(view, "#email_form")
assert has_element?(view, "#password_form")
end
test "validates email change", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/settings")
result =
view
|> element("#email_form")
|> render_change(%{"user" => %{"email" => "with spaces"}})
assert result =~ "must have the @ sign and no spaces"
end
test "submits email change", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/settings")
result =
view
|> form("#email_form", %{"user" => %{"email" => unique_user_email()}})
|> render_submit()
assert result =~ "A link to confirm your email"
end
test "validates password", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/settings")
result =
view
|> element("#password_form")
|> render_change(%{
"user" => %{
"password" => "short",
"password_confirmation" => "mismatch"
}
})
assert result =~ "should be at least 12 character(s)"
end
test "submits valid password change", %{conn: conn, user: user} do
new_password = valid_user_password()
{:ok, view, _html} = live(conn, ~p"/admin/settings")
form =
form(view, "#password_form", %{
"user" => %{
"email" => user.email,
"password" => new_password,
"password_confirmation" => new_password
}
})
render_submit(form)
new_password_conn = follow_trigger_action(form, conn)
assert redirected_to(new_password_conn) == ~p"/admin/settings"
assert Accounts.get_user_by_email_and_password(user.email, new_password)
end
end
describe "from address" do
setup %{conn: conn, user: user} do
conn = log_in_user(conn, user)
@@ -256,27 +181,12 @@ defmodule BerrypodWeb.Admin.SettingsTest do
test "saves from address", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/settings")
html =
view
|> form("form[phx-submit=\"save_from_address\"]", %{from_address: "shop@example.com"})
|> render_submit()
view
|> form("form[phx-submit=\"save_from_address\"]", %{from_address: "shop@example.com"})
|> render_submit()
assert has_element?(view, ".admin-inline-feedback-saved")
assert Settings.get_setting("email_from_address") == "shop@example.com"
end
end
describe "advanced section" do
setup %{conn: conn, user: user} do
conn = log_in_user(conn, user)
%{conn: conn}
end
test "shows links to system tools", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/settings")
assert has_element?(view, ~s(a[href="/admin/dashboard"]), "System dashboard")
assert has_element?(view, ~s(a[href="/admin/errors"]), "Error tracker")
end
end
end