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

@@ -39,6 +39,7 @@ defmodule BerrypodWeb.Auth.Login do
action={~p"/users/log-in"}
phx-submit="submit_magic"
>
<input :if={@return_to} type="hidden" name="return_to" value={@return_to} />
<.input
readonly={!!@current_scope}
field={f[:email]}
@@ -64,6 +65,7 @@ defmodule BerrypodWeb.Auth.Login do
phx-submit="submit_password"
phx-trigger-action={@trigger_submit}
>
<input :if={@return_to} type="hidden" name="return_to" value={@return_to} />
<.input
readonly={!!@current_scope}
field={f[:email]}
@@ -103,18 +105,20 @@ defmodule BerrypodWeb.Auth.Login do
end
@impl true
def mount(_params, _session, socket) do
def mount(params, _session, socket) do
email =
Phoenix.Flash.get(socket.assigns.flash, :email) ||
get_in(socket.assigns, [:current_scope, Access.key(:user), Access.key(:email)])
form = to_form(%{"email" => email}, as: "user")
return_to = params["return_to"]
{:ok,
assign(socket,
form: form,
trigger_submit: false,
email_configured: Mailer.email_verified?()
email_configured: Mailer.email_verified?(),
return_to: return_to
)}
end

View File

@@ -0,0 +1,90 @@
defmodule BerrypodWeb.Auth.TotpVerification do
use BerrypodWeb, :live_view
alias Berrypod.Accounts
@impl true
def mount(_params, session, socket) do
user_id = session["totp_pending_user_id"]
remember_me = session["totp_pending_remember_me"]
if user_id do
{:ok,
socket
|> assign(:user_id, user_id)
|> assign(:remember_me, remember_me)
|> assign(:form, to_form(%{"code" => ""}, as: :totp))
|> assign(:trigger_submit, false)}
else
{:ok,
socket
|> put_flash(:error, "Session expired. Please log in again.")
|> redirect(to: ~p"/users/log-in")}
end
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="setup-page">
<div class="setup-header">
<.header>
Two-factor authentication
<:subtitle>
Enter the 6-digit code from your authenticator app.
</:subtitle>
</.header>
</div>
<.form
for={@form}
id="totp_form"
action={~p"/users/verify-totp"}
phx-submit="verify"
phx-trigger-action={@trigger_submit}
>
<input type="hidden" name="remember_me" value={to_string(@remember_me)} />
<.input
field={@form[:code]}
type="text"
label="Verification code"
placeholder="000000"
inputmode="numeric"
pattern="[0-9]*"
autocomplete="one-time-code"
maxlength="6"
autofocus
phx-mounted={JS.focus()}
/>
<.button variant="primary" class="admin-btn-block">
Verify <span aria-hidden="true">&rarr;</span>
</.button>
</.form>
<p class="setup-footer admin-text-tertiary">
Lost your device? Enter one of your backup codes instead.
</p>
</div>
</Layouts.app>
"""
end
@impl true
def handle_event("verify", %{"totp" => %{"code" => code}}, socket) do
user = Accounts.get_user!(socket.assigns.user_id)
case Accounts.verify_totp(user, code) do
:ok ->
{:noreply, assign(socket, :trigger_submit, true)}
:error ->
{:noreply,
socket
|> assign(:form, to_form(%{"code" => ""}, as: :totp))
|> put_flash(:error, "Invalid code. Please try again.")}
end
end
end