separate account settings from shop settings
All checks were successful
deploy / deploy (push) Successful in 3m28s
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:
@@ -34,6 +34,19 @@
|
||||
<aside class="admin-sidebar">
|
||||
<%!-- nav links --%>
|
||||
<nav class="admin-sidebar-nav" aria-label="Admin navigation">
|
||||
<div class="admin-nav-group">
|
||||
<ul class="admin-nav">
|
||||
<li>
|
||||
<.link
|
||||
navigate={~p"/admin/account"}
|
||||
class={admin_nav_active?(@current_path, "/admin/account")}
|
||||
>
|
||||
<.icon name="hero-user-circle" class="size-5" /> Account
|
||||
</.link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="admin-nav-group">
|
||||
<span class="admin-nav-heading">Shop</span>
|
||||
<ul class="admin-nav">
|
||||
@@ -174,9 +187,6 @@
|
||||
<%!-- sidebar footer --%>
|
||||
<div class="admin-sidebar-footer">
|
||||
<ul class="admin-nav">
|
||||
<li class="admin-sidebar-email truncate">
|
||||
<.icon name="hero-user" class="size-5" /> {@current_scope.user.email}
|
||||
</li>
|
||||
<li>
|
||||
<details class="admin-dev-tools">
|
||||
<summary>
|
||||
|
||||
63
lib/berrypod_web/controllers/account_controller.ex
Normal file
63
lib/berrypod_web/controllers/account_controller.ex
Normal file
@@ -0,0 +1,63 @@
|
||||
defmodule BerrypodWeb.AccountController do
|
||||
@moduledoc """
|
||||
Handles account-related session operations that can't be done in LiveView.
|
||||
|
||||
These routes manage TOTP setup state in the session, which persists across
|
||||
LiveView reconnects on mobile devices.
|
||||
"""
|
||||
use BerrypodWeb, :controller
|
||||
|
||||
alias Berrypod.Accounts
|
||||
|
||||
@doc """
|
||||
Starts TOTP setup by generating a secret and storing it in the session.
|
||||
The session persists across LiveView reconnects.
|
||||
"""
|
||||
def start_totp_setup(conn, _params) do
|
||||
user = conn.assigns.current_scope.user
|
||||
|
||||
unless Accounts.sudo_mode?(user) do
|
||||
conn
|
||||
|> put_flash(:error, "Please log in again to enable 2FA.")
|
||||
|> redirect(to: ~p"/users/log-in?return_to=/admin/account")
|
||||
else
|
||||
{secret, _uri} = Accounts.generate_totp_secret(user)
|
||||
|
||||
conn
|
||||
|> put_session(:totp_setup_secret, secret)
|
||||
|> redirect(to: ~p"/admin/account")
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Clears the TOTP setup session state.
|
||||
"""
|
||||
def cancel_totp_setup(conn, _params) do
|
||||
conn
|
||||
|> delete_session(:totp_setup_secret)
|
||||
|> redirect(to: ~p"/admin/account")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Clears the TOTP setup session and stores backup codes for display.
|
||||
Called via redirect from the LiveView after successful enablement.
|
||||
"""
|
||||
def complete_totp_setup(conn, %{"codes" => codes_param}) do
|
||||
# Codes come as comma-separated string
|
||||
backup_codes = String.split(codes_param, ",")
|
||||
|
||||
conn
|
||||
|> delete_session(:totp_setup_secret)
|
||||
|> put_session(:totp_backup_codes, backup_codes)
|
||||
|> redirect(to: ~p"/admin/account")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Clears the backup codes from the session after user confirms they've saved them.
|
||||
"""
|
||||
def clear_backup_codes(conn, _params) do
|
||||
conn
|
||||
|> delete_session(:totp_backup_codes)
|
||||
|> redirect(to: ~p"/admin/account")
|
||||
end
|
||||
end
|
||||
@@ -4,7 +4,7 @@ defmodule BerrypodWeb.UserSessionController do
|
||||
alias Berrypod.Accounts
|
||||
alias BerrypodWeb.UserAuth
|
||||
|
||||
plug BerrypodWeb.Plugs.RateLimit, [type: :login] when action == :create
|
||||
plug BerrypodWeb.Plugs.RateLimit, [type: :login] when action in [:create, :verify_totp]
|
||||
|
||||
def create(conn, %{"_action" => "confirmed"} = params) do
|
||||
create(conn, params, "User confirmed successfully.")
|
||||
@@ -15,14 +15,14 @@ defmodule BerrypodWeb.UserSessionController do
|
||||
end
|
||||
|
||||
# magic link login
|
||||
defp create(conn, %{"user" => %{"token" => token} = user_params}, info) do
|
||||
defp create(conn, %{"user" => %{"token" => token} = user_params} = params, info) do
|
||||
case Accounts.login_user_by_magic_link(token) do
|
||||
{:ok, {user, tokens_to_disconnect}} ->
|
||||
UserAuth.disconnect_sessions(tokens_to_disconnect)
|
||||
|
||||
conn
|
||||
|> put_flash(:info, info)
|
||||
|> UserAuth.log_in_user(user, user_params)
|
||||
|> maybe_store_return_to(params)
|
||||
|> maybe_require_totp(user, user_params, info)
|
||||
|
||||
_ ->
|
||||
conn
|
||||
@@ -32,13 +32,13 @@ defmodule BerrypodWeb.UserSessionController do
|
||||
end
|
||||
|
||||
# email + password login
|
||||
defp create(conn, %{"user" => user_params}, info) do
|
||||
defp create(conn, %{"user" => user_params} = params, info) do
|
||||
%{"email" => email, "password" => password} = user_params
|
||||
|
||||
if user = Accounts.get_user_by_email_and_password(email, password) do
|
||||
conn
|
||||
|> put_flash(:info, info)
|
||||
|> UserAuth.log_in_user(user, user_params)
|
||||
|> maybe_store_return_to(params)
|
||||
|> maybe_require_totp(user, user_params, info)
|
||||
else
|
||||
# In order to prevent user enumeration attacks, don't disclose whether the email is registered.
|
||||
conn
|
||||
@@ -48,6 +48,55 @@ defmodule BerrypodWeb.UserSessionController do
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_store_return_to(conn, %{"return_to" => "/" <> _ = return_to}) do
|
||||
put_session(conn, :user_return_to, return_to)
|
||||
end
|
||||
|
||||
defp maybe_store_return_to(conn, _params), do: conn
|
||||
|
||||
defp maybe_require_totp(conn, user, user_params, info) do
|
||||
if Accounts.totp_enabled?(user) do
|
||||
remember_me = user_params["remember_me"] == "true"
|
||||
|
||||
conn
|
||||
|> put_session(:totp_pending_user_id, user.id)
|
||||
|> put_session(:totp_pending_remember_me, remember_me)
|
||||
|> redirect(to: ~p"/users/totp")
|
||||
else
|
||||
conn
|
||||
|> put_flash(:info, info)
|
||||
|> UserAuth.log_in_user(user, user_params)
|
||||
end
|
||||
end
|
||||
|
||||
def verify_totp(conn, %{"totp" => %{"code" => code}, "remember_me" => remember_me}) do
|
||||
user_id = get_session(conn, :totp_pending_user_id)
|
||||
|
||||
if user_id do
|
||||
user = Accounts.get_user!(user_id)
|
||||
|
||||
case Accounts.verify_totp(user, code) do
|
||||
:ok ->
|
||||
user_params = if remember_me == "true", do: %{"remember_me" => "true"}, else: %{}
|
||||
|
||||
conn
|
||||
|> delete_session(:totp_pending_user_id)
|
||||
|> delete_session(:totp_pending_remember_me)
|
||||
|> put_flash(:info, "Welcome back!")
|
||||
|> UserAuth.log_in_user(user, user_params)
|
||||
|
||||
:error ->
|
||||
conn
|
||||
|> put_flash(:error, "Invalid code. Please try again.")
|
||||
|> redirect(to: ~p"/users/totp")
|
||||
end
|
||||
else
|
||||
conn
|
||||
|> put_flash(:error, "Session expired. Please log in again.")
|
||||
|> redirect(to: ~p"/users/log-in")
|
||||
end
|
||||
end
|
||||
|
||||
def update_password(conn, %{"user" => user_params} = params) do
|
||||
user = conn.assigns.current_scope.user
|
||||
true = Accounts.sudo_mode?(user)
|
||||
@@ -57,7 +106,7 @@ defmodule BerrypodWeb.UserSessionController do
|
||||
UserAuth.disconnect_sessions(expired_tokens)
|
||||
|
||||
conn
|
||||
|> put_session(:user_return_to, ~p"/admin/settings")
|
||||
|> put_session(:user_return_to, ~p"/admin/account")
|
||||
|> create(params, "Password updated successfully!")
|
||||
end
|
||||
|
||||
|
||||
466
lib/berrypod_web/live/admin/account.ex
Normal file
466
lib/berrypod_web/live/admin/account.ex
Normal file
@@ -0,0 +1,466 @@
|
||||
defmodule BerrypodWeb.Admin.Account do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.Accounts
|
||||
|
||||
@impl true
|
||||
def mount(_params, session, socket) do
|
||||
user = socket.assigns.current_scope.user
|
||||
|
||||
# Restore TOTP setup from session if it exists (persists across reconnects)
|
||||
totp_setup =
|
||||
case session["totp_setup_secret"] do
|
||||
nil -> nil
|
||||
secret -> %{secret: secret, uri: Accounts.totp_uri(user, secret)}
|
||||
end
|
||||
|
||||
# Check for backup codes from successful TOTP enablement
|
||||
backup_codes = session["totp_backup_codes"]
|
||||
|
||||
email_changeset = Accounts.change_user_email(user, %{}, validate_unique: false)
|
||||
password_changeset = Accounts.change_user_password(user, %{}, hash_password: false)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Account")
|
||||
|> assign(:current_email, user.email)
|
||||
|> assign(:email_form, to_form(email_changeset))
|
||||
|> assign(:password_form, to_form(password_changeset))
|
||||
|> assign(:trigger_submit, false)
|
||||
|> assign(:totp_enabled, Accounts.totp_enabled?(user))
|
||||
|> assign(:totp_setup, totp_setup)
|
||||
|> assign(:totp_form, to_form(%{"code" => ""}, as: :totp))
|
||||
|> assign(:backup_codes, backup_codes)
|
||||
|> assign(:disable_totp_form, nil)}
|
||||
end
|
||||
|
||||
# -- Events: email --
|
||||
|
||||
@impl true
|
||||
def handle_event("validate_email", %{"user" => user_params}, socket) do
|
||||
email_form =
|
||||
socket.assigns.current_scope.user
|
||||
|> Accounts.change_user_email(user_params, validate_unique: false)
|
||||
|> Map.put(:action, :validate)
|
||||
|> to_form()
|
||||
|
||||
{:noreply, assign(socket, email_form: email_form)}
|
||||
end
|
||||
|
||||
def handle_event("update_email", %{"user" => user_params}, socket) do
|
||||
user = socket.assigns.current_scope.user
|
||||
|
||||
unless Accounts.sudo_mode?(user) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, "Please log in again to change account settings.")
|
||||
|> redirect(to: ~p"/users/log-in?return_to=/admin/account")}
|
||||
else
|
||||
case Accounts.change_user_email(user, user_params) do
|
||||
%{valid?: true} = changeset ->
|
||||
Accounts.deliver_user_update_email_instructions(
|
||||
Ecto.Changeset.apply_action!(changeset, :insert),
|
||||
user.email,
|
||||
&url(~p"/users/settings/confirm-email/#{&1}")
|
||||
)
|
||||
|
||||
info = "A link to confirm your email change has been sent to the new address."
|
||||
{:noreply, put_flash(socket, :info, info)}
|
||||
|
||||
changeset ->
|
||||
{:noreply, assign(socket, :email_form, to_form(changeset, action: :insert))}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# -- Events: password --
|
||||
|
||||
def handle_event("validate_password", %{"user" => user_params}, socket) do
|
||||
password_form =
|
||||
socket.assigns.current_scope.user
|
||||
|> Accounts.change_user_password(user_params, hash_password: false)
|
||||
|> Map.put(:action, :validate)
|
||||
|> to_form()
|
||||
|
||||
{:noreply, assign(socket, password_form: password_form)}
|
||||
end
|
||||
|
||||
def handle_event("update_password", %{"user" => user_params}, socket) do
|
||||
user = socket.assigns.current_scope.user
|
||||
|
||||
unless Accounts.sudo_mode?(user) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, "Please log in again to change account settings.")
|
||||
|> redirect(to: ~p"/users/log-in?return_to=/admin/account")}
|
||||
else
|
||||
case Accounts.change_user_password(user, user_params) do
|
||||
%{valid?: true} = changeset ->
|
||||
{:noreply, assign(socket, trigger_submit: true, password_form: to_form(changeset))}
|
||||
|
||||
changeset ->
|
||||
{:noreply, assign(socket, password_form: to_form(changeset, action: :insert))}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# -- Events: 2FA --
|
||||
# Note: start_totp_setup and cancel_totp_setup are handled by the controller
|
||||
# (POST /admin/account/totp/start and /admin/account/totp/cancel) to persist
|
||||
# the secret in the session, which survives LiveView reconnects on mobile.
|
||||
|
||||
def handle_event("verify_totp", %{"totp" => %{"code" => code}}, socket) do
|
||||
user = socket.assigns.current_scope.user
|
||||
setup = socket.assigns.totp_setup
|
||||
|
||||
case Accounts.enable_totp(user, setup.secret, code) do
|
||||
{:ok, _user, backup_codes} ->
|
||||
# Redirect through controller to clear the session and pass backup codes
|
||||
# This ensures reconnects don't show the setup flow again
|
||||
codes_param = Enum.join(backup_codes, ",")
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Two-factor authentication enabled")
|
||||
|> redirect(to: ~p"/admin/account/totp/complete?codes=#{codes_param}")}
|
||||
|
||||
{:error, :invalid_code} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:totp_form, to_form(%{"code" => ""}, as: :totp))
|
||||
|> put_flash(:error, "Invalid code. Please try again.")}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Something went wrong. Please try again.")}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("start_disable_totp", _params, socket) do
|
||||
user = socket.assigns.current_scope.user
|
||||
|
||||
unless Accounts.sudo_mode?(user) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, "Please log in again to disable 2FA.")
|
||||
|> redirect(to: ~p"/users/log-in?return_to=/admin/account")}
|
||||
else
|
||||
{:noreply, assign(socket, :disable_totp_form, to_form(%{"code" => ""}, as: :disable_totp))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("cancel_disable_totp", _params, socket) do
|
||||
{:noreply, assign(socket, :disable_totp_form, nil)}
|
||||
end
|
||||
|
||||
def handle_event("confirm_disable_totp", %{"disable_totp" => %{"code" => code}}, socket) do
|
||||
user = socket.assigns.current_scope.user
|
||||
|
||||
unless Accounts.sudo_mode?(user) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, "Please log in again to disable 2FA.")
|
||||
|> redirect(to: ~p"/users/log-in?return_to=/admin/account")}
|
||||
else
|
||||
case Accounts.verify_totp(user, code) do
|
||||
:ok ->
|
||||
case Accounts.disable_totp(user) do
|
||||
{:ok, _user} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:totp_enabled, false)
|
||||
|> assign(:disable_totp_form, nil)
|
||||
|> put_flash(:info, "Two-factor authentication disabled")}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Something went wrong. Please try again.")}
|
||||
end
|
||||
|
||||
:error ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:disable_totp_form, to_form(%{"code" => ""}, as: :disable_totp))
|
||||
|> put_flash(:error, "Invalid code. Please try again.")}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# -- Render --
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="admin-settings">
|
||||
<.header>
|
||||
Account
|
||||
</.header>
|
||||
|
||||
<%!-- Email --%>
|
||||
<section class="admin-section">
|
||||
<h2 class="admin-section-title">Email</h2>
|
||||
<div class="admin-section-body">
|
||||
<.form
|
||||
for={@email_form}
|
||||
id="email_form"
|
||||
phx-submit="update_email"
|
||||
phx-change="validate_email"
|
||||
>
|
||||
<.input
|
||||
field={@email_form[:email]}
|
||||
type="email"
|
||||
label="Email address"
|
||||
autocomplete="username"
|
||||
required
|
||||
/>
|
||||
<div class="admin-form-actions-sm">
|
||||
<.button phx-disable-with="Saving...">Change email</.button>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%!-- Password --%>
|
||||
<section class="admin-section">
|
||||
<h2 class="admin-section-title">Password</h2>
|
||||
<div class="admin-section-body">
|
||||
<.form
|
||||
for={@password_form}
|
||||
id="password_form"
|
||||
action={~p"/users/update-password"}
|
||||
method="post"
|
||||
phx-change="validate_password"
|
||||
phx-submit="update_password"
|
||||
phx-trigger-action={@trigger_submit}
|
||||
>
|
||||
<input
|
||||
name={@password_form[:email].name}
|
||||
type="hidden"
|
||||
id="hidden_user_email"
|
||||
autocomplete="username"
|
||||
value={@current_email}
|
||||
/>
|
||||
<.input
|
||||
field={@password_form[:password]}
|
||||
type="password"
|
||||
label="New password"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
/>
|
||||
<.input
|
||||
field={@password_form[:password_confirmation]}
|
||||
type="password"
|
||||
label="Confirm new password"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<div class="admin-form-actions-sm">
|
||||
<.button phx-disable-with="Saving...">Change password</.button>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%!-- Two-factor authentication --%>
|
||||
<section class="admin-section">
|
||||
<div class="admin-section-header">
|
||||
<h2 class="admin-section-title">Two-factor authentication</h2>
|
||||
<%= if @totp_enabled do %>
|
||||
<.status_pill color="green">
|
||||
<.icon name="hero-check-circle-mini" class="size-3" /> Enabled
|
||||
</.status_pill>
|
||||
<% else %>
|
||||
<.status_pill color="zinc">Off</.status_pill>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= if @backup_codes do %>
|
||||
<%!-- Show backup codes after enabling --%>
|
||||
<div class="admin-section-body">
|
||||
<div class="admin-info-box admin-info-box-amber">
|
||||
<p class="admin-text-bold">Save your backup codes</p>
|
||||
<p>
|
||||
These codes can be used to access your account if you lose your authenticator.
|
||||
Each code can only be used once. Store them somewhere safe.
|
||||
</p>
|
||||
<div class="admin-backup-codes">
|
||||
<%= for code <- @backup_codes do %>
|
||||
<code class="admin-backup-code">{code}</code>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="admin-form-actions-sm">
|
||||
<.link
|
||||
href={~p"/admin/account/totp/dismiss-codes"}
|
||||
method="post"
|
||||
data-confirm="Are you sure? You won't be able to see these codes again."
|
||||
class="admin-btn admin-btn-primary admin-btn-sm"
|
||||
>
|
||||
I've saved these codes
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<%= if @totp_setup do %>
|
||||
<%!-- Setup flow --%>
|
||||
<div class="admin-section-body">
|
||||
<p class="admin-section-desc admin-section-desc-flush">
|
||||
Add this account to your authenticator app, then enter the 6-digit code to verify.
|
||||
</p>
|
||||
|
||||
<div class="admin-totp-setup">
|
||||
<%!-- QR code for scanning --%>
|
||||
<div class="admin-totp-qr">
|
||||
<div class="admin-qr-code">
|
||||
{raw(qr_code_svg(@totp_setup.uri))}
|
||||
</div>
|
||||
<p class="admin-help-text">
|
||||
Scan with your authenticator app
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="admin-totp-divider">
|
||||
<span>or copy the key</span>
|
||||
</div>
|
||||
|
||||
<%!-- Copyable secret key --%>
|
||||
<div class="admin-totp-copy">
|
||||
<div class="admin-copy-field">
|
||||
<code
|
||||
id="totp-secret"
|
||||
class="admin-code-break"
|
||||
>
|
||||
{Base.encode32(@totp_setup.secret)}
|
||||
</code>
|
||||
<button
|
||||
id="copy-totp-secret"
|
||||
type="button"
|
||||
phx-hook="Clipboard"
|
||||
data-copy-target="totp-secret"
|
||||
class="admin-btn admin-btn-outline admin-btn-sm"
|
||||
>
|
||||
<.icon name="hero-clipboard" class="size-4" />
|
||||
<span>Copy</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="admin-help-text">
|
||||
Paste into your authenticator app as a manual entry (time-based/TOTP)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<.form for={@totp_form} phx-submit="verify_totp" class="admin-stack">
|
||||
<.input
|
||||
field={@totp_form[:code]}
|
||||
type="text"
|
||||
label="Verification code"
|
||||
placeholder="000000"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
autocomplete="one-time-code"
|
||||
maxlength="6"
|
||||
/>
|
||||
<div class="admin-cluster">
|
||||
<.button phx-disable-with="Verifying...">Enable 2FA</.button>
|
||||
<.link
|
||||
href={~p"/admin/account/totp/cancel"}
|
||||
method="post"
|
||||
class="admin-btn admin-btn-outline"
|
||||
>
|
||||
Cancel
|
||||
</.link>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
<% else %>
|
||||
<%!-- Enable/disable --%>
|
||||
<%= if @disable_totp_form do %>
|
||||
<%!-- Verification form for disabling --%>
|
||||
<div class="admin-section-body">
|
||||
<p class="admin-section-desc admin-section-desc-flush">
|
||||
Enter a code from your authenticator app to confirm disabling 2FA.
|
||||
</p>
|
||||
<.form for={@disable_totp_form} phx-submit="confirm_disable_totp" class="admin-stack">
|
||||
<.input
|
||||
field={@disable_totp_form[:code]}
|
||||
type="text"
|
||||
label="Verification code"
|
||||
placeholder="000000"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
autocomplete="one-time-code"
|
||||
maxlength="6"
|
||||
/>
|
||||
<div class="admin-cluster">
|
||||
<.button class="admin-btn-danger" phx-disable-with="Disabling...">
|
||||
Disable 2FA
|
||||
</.button>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="cancel_disable_totp"
|
||||
class="admin-btn admin-btn-outline"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="admin-section-desc">
|
||||
<%= if @totp_enabled do %>
|
||||
Your account is protected with two-factor authentication.
|
||||
<% else %>
|
||||
Add an extra layer of security by requiring a code from your authenticator app when logging in.
|
||||
<% end %>
|
||||
</p>
|
||||
<div class="admin-section-body">
|
||||
<%= if @totp_enabled do %>
|
||||
<button
|
||||
phx-click="start_disable_totp"
|
||||
class="admin-btn admin-btn-outline admin-btn-sm"
|
||||
>
|
||||
Disable 2FA
|
||||
</button>
|
||||
<% else %>
|
||||
<.link
|
||||
href={~p"/admin/account/totp/start"}
|
||||
method="post"
|
||||
class="admin-btn admin-btn-primary admin-btn-sm"
|
||||
>
|
||||
<.icon name="hero-shield-check-mini" class="size-4" /> Enable 2FA
|
||||
</.link>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</section>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# -- Function components --
|
||||
|
||||
attr :color, :string, required: true
|
||||
slot :inner_block, required: true
|
||||
|
||||
defp status_pill(assigns) do
|
||||
modifier =
|
||||
case assigns.color do
|
||||
"green" -> "admin-status-pill-green"
|
||||
"amber" -> "admin-status-pill-amber"
|
||||
_ -> "admin-status-pill-zinc"
|
||||
end
|
||||
|
||||
assigns = assign(assigns, :modifier, modifier)
|
||||
|
||||
~H"""
|
||||
<span class={["admin-status-pill", @modifier]}>
|
||||
{render_slot(@inner_block)}
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp qr_code_svg(uri) do
|
||||
uri
|
||||
|> EQRCode.encode()
|
||||
|> EQRCode.svg(width: 200)
|
||||
end
|
||||
end
|
||||
@@ -1,7 +1,6 @@
|
||||
defmodule BerrypodWeb.Admin.Settings do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.Accounts
|
||||
alias Berrypod.Products
|
||||
alias Berrypod.Settings
|
||||
alias Berrypod.Stripe.Setup, as: StripeSetup
|
||||
@@ -19,8 +18,7 @@ defmodule BerrypodWeb.Admin.Settings do
|
||||
|> assign(:from_address_status, :idle)
|
||||
|> assign(:signing_secret_status, :idle)
|
||||
|> assign_stripe_state()
|
||||
|> assign_products_state()
|
||||
|> assign_account_state(user)}
|
||||
|> assign_products_state()}
|
||||
end
|
||||
|
||||
# -- Stripe assigns --
|
||||
@@ -66,19 +64,6 @@ defmodule BerrypodWeb.Admin.Settings do
|
||||
assign(socket, :provider, connection_info)
|
||||
end
|
||||
|
||||
# -- Account assigns --
|
||||
|
||||
defp assign_account_state(socket, user) do
|
||||
email_changeset = Accounts.change_user_email(user, %{}, validate_unique: false)
|
||||
password_changeset = Accounts.change_user_password(user, %{}, hash_password: false)
|
||||
|
||||
socket
|
||||
|> assign(:current_email, user.email)
|
||||
|> assign(:email_form, to_form(email_changeset))
|
||||
|> assign(:password_form, to_form(password_changeset))
|
||||
|> assign(:trigger_submit, false)
|
||||
end
|
||||
|
||||
# -- Events: shop status --
|
||||
|
||||
@impl true
|
||||
@@ -232,73 +217,6 @@ defmodule BerrypodWeb.Admin.Settings do
|
||||
|> put_flash(:info, "Provider connection deleted")}
|
||||
end
|
||||
|
||||
# -- Events: account --
|
||||
|
||||
def handle_event("validate_email", %{"user" => user_params}, socket) do
|
||||
email_form =
|
||||
socket.assigns.current_scope.user
|
||||
|> Accounts.change_user_email(user_params, validate_unique: false)
|
||||
|> Map.put(:action, :validate)
|
||||
|> to_form()
|
||||
|
||||
{:noreply, assign(socket, email_form: email_form)}
|
||||
end
|
||||
|
||||
def handle_event("update_email", %{"user" => user_params}, socket) do
|
||||
user = socket.assigns.current_scope.user
|
||||
|
||||
unless Accounts.sudo_mode?(user) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, "Please log in again to change account settings.")
|
||||
|> redirect(to: ~p"/users/log-in")}
|
||||
else
|
||||
case Accounts.change_user_email(user, user_params) do
|
||||
%{valid?: true} = changeset ->
|
||||
Accounts.deliver_user_update_email_instructions(
|
||||
Ecto.Changeset.apply_action!(changeset, :insert),
|
||||
user.email,
|
||||
&url(~p"/users/settings/confirm-email/#{&1}")
|
||||
)
|
||||
|
||||
info = "A link to confirm your email change has been sent to the new address."
|
||||
{:noreply, put_flash(socket, :info, info)}
|
||||
|
||||
changeset ->
|
||||
{:noreply, assign(socket, :email_form, to_form(changeset, action: :insert))}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("validate_password", %{"user" => user_params}, socket) do
|
||||
password_form =
|
||||
socket.assigns.current_scope.user
|
||||
|> Accounts.change_user_password(user_params, hash_password: false)
|
||||
|> Map.put(:action, :validate)
|
||||
|> to_form()
|
||||
|
||||
{:noreply, assign(socket, password_form: password_form)}
|
||||
end
|
||||
|
||||
def handle_event("update_password", %{"user" => user_params}, socket) do
|
||||
user = socket.assigns.current_scope.user
|
||||
|
||||
unless Accounts.sudo_mode?(user) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, "Please log in again to change account settings.")
|
||||
|> redirect(to: ~p"/users/log-in")}
|
||||
else
|
||||
case Accounts.change_user_password(user, user_params) do
|
||||
%{valid?: true} = changeset ->
|
||||
{:noreply, assign(socket, trigger_submit: true, password_form: to_form(changeset))}
|
||||
|
||||
changeset ->
|
||||
{:noreply, assign(socket, password_form: to_form(changeset, action: :insert))}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# -- Render --
|
||||
|
||||
@impl true
|
||||
@@ -470,81 +388,6 @@ defmodule BerrypodWeb.Admin.Settings do
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%!-- Account --%>
|
||||
<section class="admin-section">
|
||||
<h2 class="admin-section-title">Account</h2>
|
||||
|
||||
<div class="admin-stack admin-stack-lg admin-section-body">
|
||||
<.form
|
||||
for={@email_form}
|
||||
id="email_form"
|
||||
phx-submit="update_email"
|
||||
phx-change="validate_email"
|
||||
>
|
||||
<.input
|
||||
field={@email_form[:email]}
|
||||
type="email"
|
||||
label="Email"
|
||||
autocomplete="username"
|
||||
required
|
||||
/>
|
||||
<div class="admin-form-actions-sm">
|
||||
<.button phx-disable-with="Saving...">Change email</.button>
|
||||
</div>
|
||||
</.form>
|
||||
|
||||
<div class="admin-separator-xl">
|
||||
<.form
|
||||
for={@password_form}
|
||||
id="password_form"
|
||||
action={~p"/users/update-password"}
|
||||
method="post"
|
||||
phx-change="validate_password"
|
||||
phx-submit="update_password"
|
||||
phx-trigger-action={@trigger_submit}
|
||||
>
|
||||
<input
|
||||
name={@password_form[:email].name}
|
||||
type="hidden"
|
||||
id="hidden_user_email"
|
||||
autocomplete="username"
|
||||
value={@current_email}
|
||||
/>
|
||||
<.input
|
||||
field={@password_form[:password]}
|
||||
type="password"
|
||||
label="New password"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
/>
|
||||
<.input
|
||||
field={@password_form[:password_confirmation]}
|
||||
type="password"
|
||||
label="Confirm new password"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<div class="admin-form-actions-sm">
|
||||
<.button phx-disable-with="Saving...">Change password</.button>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%!-- Advanced --%>
|
||||
<section class="admin-section">
|
||||
<h2 class="admin-section-title">Advanced</h2>
|
||||
|
||||
<div class="admin-stack admin-stack-sm admin-section-body">
|
||||
<.link href={~p"/admin/dashboard"} class="admin-link-subtle">
|
||||
<.icon name="hero-chart-bar" class="size-4 inline" /> System dashboard
|
||||
</.link>
|
||||
<.link href={~p"/admin/errors"} class="admin-link-subtle">
|
||||
<.icon name="hero-bug-ant" class="size-4 inline" /> Error tracker
|
||||
</.link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
90
lib/berrypod_web/live/auth/totp_verification.ex
Normal file
90
lib/berrypod_web/live/auth/totp_verification.ex
Normal 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">→</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
|
||||
@@ -143,6 +143,11 @@ defmodule BerrypodWeb.Router do
|
||||
post "/settings/email/test", EmailSettingsController, :test
|
||||
post "/settings/from-address", SettingsController, :update_from_address
|
||||
post "/settings/stripe/signing-secret", SettingsController, :update_signing_secret
|
||||
# Account TOTP routes (session-based for mobile reconnect persistence)
|
||||
post "/account/totp/start", AccountController, :start_totp_setup
|
||||
post "/account/totp/cancel", AccountController, :cancel_totp_setup
|
||||
get "/account/totp/complete", AccountController, :complete_totp_setup
|
||||
post "/account/totp/dismiss-codes", AccountController, :clear_backup_codes
|
||||
post "/navigation", NavigationController, :save
|
||||
post "/providers", ProvidersController, :create
|
||||
post "/providers/:id", ProvidersController, :update
|
||||
@@ -165,6 +170,7 @@ defmodule BerrypodWeb.Router do
|
||||
live "/providers/:id/edit", Admin.Providers.Form, :edit
|
||||
live "/settings", Admin.Settings, :index
|
||||
live "/settings/email", Admin.EmailSettings, :index
|
||||
live "/account", Admin.Account, :index
|
||||
live "/pages", Admin.Pages.Index, :index
|
||||
live "/pages/new", Admin.Pages.CustomForm, :new
|
||||
live "/pages/:slug/settings", Admin.Pages.CustomForm, :edit
|
||||
@@ -208,9 +214,11 @@ defmodule BerrypodWeb.Router do
|
||||
live "/users/register", Auth.Registration, :new
|
||||
live "/users/log-in", Auth.Login, :new
|
||||
live "/users/log-in/:token", Auth.Confirmation, :new
|
||||
live "/users/totp", Auth.TotpVerification, :new
|
||||
end
|
||||
|
||||
post "/users/log-in", UserSessionController, :create
|
||||
post "/users/verify-totp", UserSessionController, :verify_totp
|
||||
delete "/users/log-out", UserSessionController, :delete
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user