aprs.me/lib/aprsme_web/live/user_settings_live.ex
Graham McIntire 4481888976
Fix settings page showing validation errors on initial load
The email and callsign changesets add "did not change" errors when
created with empty attrs (on mount). Guard all field error displays
on changeset.action being set so errors only appear after user
interaction.
2026-02-20 17:09:18 -06:00

419 lines
20 KiB
Elixir

defmodule AprsmeWeb.UserSettingsLive do
@moduledoc false
use AprsmeWeb, :live_view
import AprsmeWeb.CoreComponents, only: [translate_error: 1, simple_form: 1]
alias Aprsme.Accounts
def render(assigns) do
~H"""
<div class="min-h-screen bg-gray-50 py-12 dark:bg-gray-900">
<div class="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8">
<div class="text-center mb-8">
<h1 class="text-4xl font-bold text-gray-900 dark:text-white">Account Settings</h1>
<p class="mt-2 text-gray-500 dark:text-gray-400">Update your email address, callsign, and password</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Change Email Section -->
<div class="bg-white px-6 py-8 shadow-sm sm:rounded-lg dark:bg-gray-800/50 dark:shadow-none dark:outline dark:-outline-offset-1 dark:outline-white/10">
<h2 class="text-xl font-semibold text-gray-900 mb-6 dark:text-white">Change Email</h2>
<.simple_form
:let={f}
id="email_form"
for={@email_changeset}
phx-submit="update_email"
phx-change="validate_email"
>
<div :if={@email_changeset.action == :insert} class="rounded-md bg-red-50 p-4 dark:bg-red-500/10">
<p class="text-sm text-red-700 dark:text-red-400">
Oops, something went wrong! Please check the errors below.
</p>
</div>
<div>
<label class="block text-sm/6 font-medium text-gray-900 dark:text-white">Email</label>
<div class="mt-2">
<input
type="email"
name={Phoenix.HTML.Form.input_name(f, :email)}
value={Phoenix.HTML.Form.input_value(f, :email) || ""}
class={[
"block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:placeholder:text-gray-500 dark:focus:outline-indigo-500",
if(@email_changeset.action && Keyword.has_key?(@email_changeset.errors, :email),
do: "outline-red-500 dark:outline-red-400",
else: "outline-gray-300 dark:outline-white/10"
)
]}
placeholder="Enter your email"
required
/>
</div>
<p
:if={@email_changeset.action && Keyword.has_key?(@email_changeset.errors, :email)}
class="mt-1 text-sm text-red-600 dark:text-red-400"
>
{translate_error(Keyword.get(@email_changeset.errors, :email))}
</p>
</div>
<div>
<label class="block text-sm/6 font-medium text-gray-900 dark:text-white">Current password</label>
<div class="mt-2">
<input
type="password"
name="current_password"
id="current_password_for_email"
value={@email_form_current_password || ""}
class={[
"block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:placeholder:text-gray-500 dark:focus:outline-indigo-500",
if(@email_changeset.action && Keyword.has_key?(@email_changeset.errors, :current_password),
do: "outline-red-500 dark:outline-red-400",
else: "outline-gray-300 dark:outline-white/10"
)
]}
placeholder="Enter current password"
required
/>
</div>
<p
:if={@email_changeset.action && Keyword.has_key?(@email_changeset.errors, :current_password)}
class="mt-1 text-sm text-red-600 dark:text-red-400"
>
{translate_error(Keyword.get(@email_changeset.errors, :current_password))}
</p>
</div>
<div>
<button
type="submit"
class="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm/6 font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-indigo-500 dark:hover:bg-indigo-400 dark:focus-visible:outline-indigo-500"
phx-disable-with="Changing..."
>
Change Email
</button>
</div>
</.simple_form>
</div>
<!-- Change Callsign Section -->
<div class="bg-white px-6 py-8 shadow-sm sm:rounded-lg dark:bg-gray-800/50 dark:shadow-none dark:outline dark:-outline-offset-1 dark:outline-white/10">
<h2 class="text-xl font-semibold text-gray-900 mb-6 dark:text-white">Change Callsign</h2>
<.simple_form
:let={f}
id="callsign_form"
for={@callsign_changeset}
phx-submit="update_callsign"
phx-change="validate_callsign"
>
<div :if={@callsign_changeset.action == :insert} class="rounded-md bg-red-50 p-4 dark:bg-red-500/10">
<p class="text-sm text-red-700 dark:text-red-400">
Oops, something went wrong! Please check the errors below.
</p>
</div>
<div>
<label class="block text-sm/6 font-medium text-gray-900 dark:text-white">Callsign</label>
<div class="mt-2">
<input
type="text"
name={Phoenix.HTML.Form.input_name(f, :callsign)}
value={Phoenix.HTML.Form.input_value(f, :callsign) || ""}
class={[
"block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:placeholder:text-gray-500 dark:focus:outline-indigo-500",
if(@callsign_changeset.action && Keyword.has_key?(@callsign_changeset.errors, :callsign),
do: "outline-red-500 dark:outline-red-400",
else: "outline-gray-300 dark:outline-white/10"
)
]}
placeholder="Enter your callsign"
required
/>
</div>
<p
:if={@callsign_changeset.action && Keyword.has_key?(@callsign_changeset.errors, :callsign)}
class="mt-1 text-sm text-red-600 dark:text-red-400"
>
{translate_error(Keyword.get(@callsign_changeset.errors, :callsign))}
</p>
</div>
<div>
<label class="block text-sm/6 font-medium text-gray-900 dark:text-white">Current password</label>
<div class="mt-2">
<input
type="password"
name="current_password"
id="current_password_for_callsign"
value={@callsign_form_current_password || ""}
class={[
"block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:placeholder:text-gray-500 dark:focus:outline-indigo-500",
if(@callsign_changeset.action && Keyword.has_key?(@callsign_changeset.errors, :current_password),
do: "outline-red-500 dark:outline-red-400",
else: "outline-gray-300 dark:outline-white/10"
)
]}
placeholder="Enter current password"
required
/>
</div>
<p
:if={@callsign_changeset.action && Keyword.has_key?(@callsign_changeset.errors, :current_password)}
class="mt-1 text-sm text-red-600 dark:text-red-400"
>
{translate_error(Keyword.get(@callsign_changeset.errors, :current_password))}
</p>
</div>
<div>
<button
type="submit"
class="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm/6 font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-indigo-500 dark:hover:bg-indigo-400 dark:focus-visible:outline-indigo-500"
phx-disable-with="Changing..."
>
Change Callsign
</button>
</div>
</.simple_form>
</div>
<!-- Change Password Section -->
<div class="bg-white px-6 py-8 shadow-sm sm:rounded-lg dark:bg-gray-800/50 dark:shadow-none dark:outline dark:-outline-offset-1 dark:outline-white/10">
<h2 class="text-xl font-semibold text-gray-900 mb-6 dark:text-white">Change Password</h2>
<.simple_form
:let={f}
id="password_form"
for={@password_changeset}
action={~p"/users/log_in?_action=password_updated"}
method="post"
phx-change="validate_password"
phx-submit="update_password"
phx-trigger-action={@trigger_submit}
>
<div :if={@password_changeset.action == :insert} class="rounded-md bg-red-50 p-4 dark:bg-red-500/10">
<p class="text-sm text-red-700 dark:text-red-400">
Oops, something went wrong! Please check the errors below.
</p>
</div>
<input type="hidden" name={Phoenix.HTML.Form.input_name(f, :email)} value={@current_email} />
<div>
<label class="block text-sm/6 font-medium text-gray-900 dark:text-white">New password</label>
<div class="mt-2">
<input
type="password"
name={Phoenix.HTML.Form.input_name(f, :password)}
value={Phoenix.HTML.Form.input_value(f, :password) || ""}
class={[
"block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:placeholder:text-gray-500 dark:focus:outline-indigo-500",
if(@password_changeset.action && Keyword.has_key?(@password_changeset.errors, :password),
do: "outline-red-500 dark:outline-red-400",
else: "outline-gray-300 dark:outline-white/10"
)
]}
placeholder="Enter new password"
required
/>
</div>
<p
:if={@password_changeset.action && Keyword.has_key?(@password_changeset.errors, :password)}
class="mt-1 text-sm text-red-600 dark:text-red-400"
>
{translate_error(Keyword.get(@password_changeset.errors, :password))}
</p>
</div>
<div>
<label class="block text-sm/6 font-medium text-gray-900 dark:text-white">Confirm new password</label>
<div class="mt-2">
<input
type="password"
name={Phoenix.HTML.Form.input_name(f, :password_confirmation)}
value={Phoenix.HTML.Form.input_value(f, :password_confirmation) || ""}
class={[
"block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:placeholder:text-gray-500 dark:focus:outline-indigo-500",
if(@password_changeset.action && Keyword.has_key?(@password_changeset.errors, :password_confirmation),
do: "outline-red-500 dark:outline-red-400",
else: "outline-gray-300 dark:outline-white/10"
)
]}
placeholder="Confirm new password"
required
/>
</div>
<p
:if={@password_changeset.action && Keyword.has_key?(@password_changeset.errors, :password_confirmation)}
class="mt-1 text-sm text-red-600 dark:text-red-400"
>
{translate_error(Keyword.get(@password_changeset.errors, :password_confirmation))}
</p>
</div>
<div>
<label class="block text-sm/6 font-medium text-gray-900 dark:text-white">Current password</label>
<div class="mt-2">
<input
type="password"
name="current_password"
id="current_password_for_password"
value={@current_password || ""}
class={[
"block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:placeholder:text-gray-500 dark:focus:outline-indigo-500",
if(@password_changeset.action && Keyword.has_key?(@password_changeset.errors, :current_password),
do: "outline-red-500 dark:outline-red-400",
else: "outline-gray-300 dark:outline-white/10"
)
]}
placeholder="Enter current password"
required
/>
</div>
<p
:if={@password_changeset.action && Keyword.has_key?(@password_changeset.errors, :current_password)}
class="mt-1 text-sm text-red-600 dark:text-red-400"
>
{translate_error(Keyword.get(@password_changeset.errors, :current_password))}
</p>
</div>
<div>
<button
type="submit"
class="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm/6 font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-indigo-500 dark:hover:bg-indigo-400 dark:focus-visible:outline-indigo-500"
phx-disable-with="Changing..."
>
Change Password
</button>
</div>
</.simple_form>
</div>
</div>
</div>
</div>
"""
end
def mount(%{"token" => token}, _session, socket) do
socket =
case Accounts.update_user_email(socket.assigns.current_user, token) do
:ok ->
put_flash(socket, :info, "Email changed successfully.")
:error ->
put_flash(socket, :error, "Email change link is invalid or it has expired.")
end
{:ok, push_navigate(socket, to: ~p"/users/settings")}
end
def mount(_params, _session, socket) do
user = socket.assigns.current_user
socket =
socket
|> assign(:current_password, nil)
|> assign(:email_form_current_password, nil)
|> assign(:callsign_form_current_password, nil)
|> assign(:current_email, user.email)
|> assign(:current_callsign, user.callsign)
|> assign(:email_changeset, Accounts.change_user_email(user))
|> assign(:callsign_changeset, Accounts.change_user_callsign(user))
|> assign(:password_changeset, Accounts.change_user_password(user))
|> assign(:trigger_submit, false)
{:ok, socket}
end
def handle_event("validate_email", params, socket) do
%{"current_password" => password, "user" => user_params} = params
email_changeset = Accounts.change_user_email(socket.assigns.current_user, user_params)
socket =
assign(socket,
email_changeset: Map.put(email_changeset, :action, :validate),
email_form_current_password: password
)
{:noreply, socket}
end
def handle_event("update_email", params, socket) do
%{"current_password" => password, "user" => user_params} = params
user = socket.assigns.current_user
case Accounts.apply_user_email(user, password, user_params) do
{:ok, applied_user} ->
Accounts.deliver_user_update_email_instructions(
applied_user,
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)}
{:error, changeset} ->
{:noreply, assign(socket, :email_changeset, Map.put(changeset, :action, :insert))}
end
end
def handle_event("validate_callsign", params, socket) do
%{"current_password" => password, "user" => user_params} = params
callsign_changeset = Accounts.change_user_callsign(socket.assigns.current_user, user_params)
socket =
assign(socket,
callsign_changeset: Map.put(callsign_changeset, :action, :validate),
callsign_form_current_password: password
)
{:noreply, socket}
end
def handle_event("update_callsign", params, socket) do
%{"current_password" => password, "user" => user_params} = params
user = socket.assigns.current_user
case Accounts.update_user_callsign(user, password, user_params) do
{:ok, _user} ->
info = "Callsign updated successfully."
{:noreply, socket |> put_flash(:info, info) |> push_navigate(to: ~p"/users/settings")}
{:error, changeset} ->
{:noreply, assign(socket, :callsign_changeset, Map.put(changeset, :action, :insert))}
end
end
def handle_event("validate_password", params, socket) do
%{"current_password" => password, "user" => user_params} = params
password_changeset = Accounts.change_user_password(socket.assigns.current_user, user_params)
{:noreply,
socket
|> assign(:password_changeset, Map.put(password_changeset, :action, :validate))
|> assign(:current_password, password)}
end
def handle_event("update_password", params, socket) do
%{"current_password" => password, "user" => user_params} = params
user = socket.assigns.current_user
case Accounts.update_user_password(user, password, user_params) do
{:ok, user} ->
socket =
socket
|> assign(:trigger_submit, true)
|> assign(:password_changeset, Accounts.change_user_password(user, user_params))
{:noreply, socket}
{:error, changeset} ->
{:noreply, assign(socket, :password_changeset, changeset)}
end
end
end