berrypod/lib/berrypod_web/live/admin/account.ex

467 lines
16 KiB
Elixir
Raw Permalink Normal View History

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