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"""
<.header> Account <%!-- Email --%>

Email

<.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 />
<.button phx-disable-with="Saving...">Change email
<%!-- Password --%>

Password

<.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 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" />
<.button phx-disable-with="Saving...">Change password
<%!-- Two-factor authentication --%>

Two-factor authentication

<%= if @totp_enabled do %> <.status_pill color="green"> <.icon name="hero-check-circle-mini" class="size-3" /> Enabled <% else %> <.status_pill color="zinc">Off <% end %>
<%= if @backup_codes do %> <%!-- Show backup codes after enabling --%>

Save your backup codes

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.

<%= for code <- @backup_codes do %> {code} <% end %>
<.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
<% else %> <%= if @totp_setup do %> <%!-- Setup flow --%>

Add this account to your authenticator app, then enter the 6-digit code to verify.

<%!-- QR code for scanning --%>
{raw(qr_code_svg(@totp_setup.uri))}

Scan with your authenticator app

or copy the key
<%!-- Copyable secret key --%>
{Base.encode32(@totp_setup.secret)}

Paste into your authenticator app as a manual entry (time-based/TOTP)

<.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" />
<.button phx-disable-with="Verifying...">Enable 2FA <.link href={~p"/admin/account/totp/cancel"} method="post" class="admin-btn admin-btn-outline" > Cancel
<% else %> <%!-- Enable/disable --%> <%= if @disable_totp_form do %> <%!-- Verification form for disabling --%>

Enter a code from your authenticator app to confirm disabling 2FA.

<.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" />
<.button class="admin-btn-danger" phx-disable-with="Disabling..."> Disable 2FA
<% else %>

<%= 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 %>

<%= if @totp_enabled do %> <% 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 <% end %>
<% end %> <% end %> <% end %>
""" 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""" {render_slot(@inner_block)} """ end defp qr_code_svg(uri) do uri |> EQRCode.encode() |> EQRCode.svg(width: 200) end end