towerops/lib/towerops_web/live/user_reset_password_live.ex
Graham McIntire f2608e8f16
fix: status emoji accumulating in page title and add missing page titles
Fix JS regex in StatusTitle hook to use unicode flag so emoji are
properly matched and replaced instead of accumulating. Add page_title
to 8 LiveViews that were missing it, which caused "TowerOps | TowerOps"
duplication from the live_title suffix.
2026-03-11 09:54:18 -05:00

142 lines
4.6 KiB
Elixir

defmodule ToweropsWeb.UserResetPasswordLive do
@moduledoc """
LiveView for resetting user password with HIBP password breach checking.
"""
use ToweropsWeb, :live_view
alias Towerops.Accounts
alias Towerops.Accounts.HIBP
def mount(%{"token" => token}, _session, socket) do
case Accounts.get_user_by_reset_password_token(token) do
nil ->
{:ok,
socket
|> put_flash(:error, t("Reset password link is invalid or has expired."))
|> redirect(to: ~p"/users/log-in")}
user ->
changeset = Accounts.change_user_password(user)
{:ok,
socket
|> assign(:page_title, t("Reset Password"))
|> assign(:form, to_form(changeset))
|> assign(:token, token)
|> assign(:user, user)
|> assign(:password_breach_count, nil)}
end
end
def handle_event("check_password_breach", %{"value" => password}, socket)
when is_binary(password) and byte_size(password) > 0 do
case HIBP.check_password(password) do
{:ok, count} when is_integer(count) ->
{:noreply, assign(socket, :password_breach_count, count)}
{:ok, :unknown} ->
{:noreply, assign(socket, :password_breach_count, nil)}
end
end
def handle_event("check_password_breach", _params, socket) do
{:noreply, socket}
end
def handle_event("validate", %{"user" => user_params}, socket) do
changeset =
socket.assigns.user
|> Accounts.change_user_password(user_params)
|> Map.put(:action, :validate)
{:noreply, assign(socket, :form, to_form(changeset))}
end
def handle_event("save", %{"user" => user_params}, socket) do
case Accounts.reset_user_password(socket.assigns.user, user_params) do
{:ok, {updated_user, _expired_tokens}} ->
{:noreply,
socket
|> put_flash(:info, t("Password reset successfully."))
|> redirect(to: ~p"/users/log-in?email=#{updated_user.email}")}
{:error, changeset} ->
{:noreply, assign(socket, :form, to_form(changeset))}
end
end
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<div class="mx-auto max-w-sm">
<div class="text-center">
<.header>
Reset password
<:subtitle>
Enter your new password below.
</:subtitle>
</.header>
</div>
<div class="mt-8">
<.form for={@form} phx-change="validate" phx-submit="save">
<.input
field={@form[:password]}
type="password"
label="New password"
autocomplete="new-password"
phx-blur="check_password_breach"
phx-mounted={JS.focus()}
required
/>
<%= if @password_breach_count && @password_breach_count > 0 do %>
<div class="mt-2 rounded-md bg-yellow-50 dark:bg-yellow-900/20 p-4">
<div class="flex">
<div class="flex-shrink-0">
<.icon name="hero-exclamation-triangle" class="h-5 w-5 text-yellow-400" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-200">
Password found in data breaches
</h3>
<div class="mt-2 text-sm text-yellow-700 dark:text-yellow-300">
<p>
This password has appeared {@password_breach_count}
{if @password_breach_count == 1, do: "time", else: "times"} in known data breaches. We strongly recommend choosing a different,
more unique password to protect your account.
</p>
</div>
</div>
</div>
</div>
<% end %>
<.input
field={@form[:password_confirmation]}
type="password"
label="Confirm new password"
autocomplete="new-password"
required
/>
<div class="mt-6">
<.button phx-disable-with="Resetting password..." class="w-full" variant="primary">
Reset password
</.button>
</div>
</.form>
<div class="mt-6 text-center">
<.link
navigate={~p"/users/log-in"}
class="text-sm font-semibold text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
Back to log in
</.link>
</div>
</div>
</div>
</Layouts.app>
"""
end
end