add callsign to users

This commit is contained in:
Graham McIntire 2025-07-10 16:07:05 -05:00
parent 73d67da457
commit 38920e9f3d
No known key found for this signature in database
9 changed files with 567 additions and 10 deletions

View file

@ -181,6 +181,46 @@ defmodule Aprsme.Accounts do
UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token))
end
@doc """
Returns an `%Ecto.Changeset{}` for changing the user callsign.
## Examples
iex> change_user_callsign(user)
%Ecto.Changeset{data: %User{}}
"""
def change_user_callsign(user, attrs \\ %{}) do
User.callsign_changeset(user, attrs, validate_callsign: false)
end
@doc """
Updates the user callsign with password validation.
## Examples
iex> update_user_callsign(user, "valid password", %{callsign: "K1ABC"})
{:ok, %User{}}
iex> update_user_callsign(user, "invalid password", %{callsign: "K1ABC"})
{:error, %Ecto.Changeset{}}
"""
def update_user_callsign(user, password, attrs) do
changeset =
user
|> User.callsign_changeset(attrs)
|> User.validate_current_password(password)
Ecto.Multi.new()
|> Ecto.Multi.update(:user, changeset)
|> Repo.transaction()
|> case do
{:ok, %{user: user}} -> {:ok, user}
{:error, :user, changeset, _} -> {:error, changeset}
end
end
@doc """
Returns an `%Ecto.Changeset{}` for changing the user password.

View file

@ -6,6 +6,7 @@ defmodule Aprsme.Accounts.User do
schema "users" do
field(:email, :string)
field(:callsign, :string)
field(:password, :string, virtual: true, redact: true)
field(:hashed_password, :string, redact: true)
field(:confirmed_at, :naive_datetime)
@ -38,8 +39,9 @@ defmodule Aprsme.Accounts.User do
"""
def registration_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:email, :password])
|> cast(attrs, [:email, :password, :callsign])
|> validate_email(opts)
|> validate_callsign(opts)
|> validate_password(opts)
end
@ -51,6 +53,17 @@ defmodule Aprsme.Accounts.User do
|> maybe_validate_unique_email(opts)
end
defp validate_callsign(changeset, opts) do
changeset
|> validate_required([:callsign])
|> validate_format(:callsign, ~r/^[A-Z]{1,2}[0-9]{1,2}[A-Z]{1,3}$/i,
message: "must be a valid amateur radio callsign"
)
|> update_change(:callsign, &String.upcase/1)
|> validate_length(:callsign, min: 3, max: 10)
|> maybe_validate_unique_callsign(opts)
end
defp validate_password(changeset, opts) do
changeset
|> validate_required([:password])
@ -95,6 +108,19 @@ defmodule Aprsme.Accounts.User do
defp do_validate_unique_email(changeset, false), do: changeset
defp maybe_validate_unique_callsign(changeset, opts) do
validate_callsign? = Keyword.get(opts, :validate_callsign, true)
do_validate_unique_callsign(changeset, validate_callsign?)
end
defp do_validate_unique_callsign(changeset, true) do
changeset
|> unsafe_validate_unique(:callsign, Aprsme.Repo)
|> unique_constraint(:callsign)
end
defp do_validate_unique_callsign(changeset, false), do: changeset
@doc """
A user changeset for changing the email.
@ -110,6 +136,21 @@ defmodule Aprsme.Accounts.User do
end
end
@doc """
A user changeset for changing the callsign.
It requires the callsign to change otherwise an error is added.
"""
def callsign_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:callsign])
|> validate_callsign(opts)
|> case do
%{changes: %{callsign: _}} = changeset -> changeset
%{} = changeset -> add_error(changeset, :callsign, "did not change")
end
end
@doc """
A user changeset for changing the password.

View file

@ -60,6 +60,28 @@ defmodule AprsmeWeb.UserRegistrationLive do
</label>
</div>
<div class="form-control w-full">
<label class="label">
<span class="label-text">Callsign</span>
</label>
<input
type="text"
name={Phoenix.HTML.Form.input_name(f, :callsign)}
value={Phoenix.HTML.Form.input_value(f, :callsign) || ""}
class={[
"input input-bordered w-full bg-base-100 text-base-content",
Keyword.has_key?(@changeset.errors, :callsign) && "input-error"
]}
placeholder="Enter your amateur radio callsign"
required
/>
<label :if={Keyword.has_key?(@changeset.errors, :callsign)} class="label">
<span class="label-text-alt text-error">
{translate_error(Keyword.get(@changeset.errors, :callsign))}
</span>
</label>
</div>
<div class="form-control w-full">
<label class="label">
<span class="label-text">Password</span>

View file

@ -2,6 +2,8 @@ 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
@ -10,10 +12,10 @@ defmodule AprsmeWeb.UserSettingsLive do
<div class="hero-content flex-col w-full max-w-4xl">
<div class="text-center mb-8">
<h1 class="text-4xl font-bold">Account Settings</h1>
<p class="text-base-content/70">Update your email address and password</p>
<p class="text-base-content/70">Update your email address, callsign, and password</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 w-full">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8 w-full">
<!-- Change Email Section -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
@ -38,10 +40,18 @@ defmodule AprsmeWeb.UserSettingsLive do
type="email"
name={Phoenix.HTML.Form.input_name(f, :email)}
value={Phoenix.HTML.Form.input_value(f, :email) || ""}
class="input input-bordered w-full bg-base-100 text-base-content"
class={[
"input input-bordered w-full bg-base-100 text-base-content",
Keyword.has_key?(@email_changeset.errors, :email) && "input-error"
]}
placeholder="Enter your email"
required
/>
<label :if={Keyword.has_key?(@email_changeset.errors, :email)} class="label">
<span class="label-text-alt text-error">
{translate_error(Keyword.get(@email_changeset.errors, :email))}
</span>
</label>
</div>
<div class="form-control w-full">
@ -53,10 +63,21 @@ defmodule AprsmeWeb.UserSettingsLive do
name="current_password"
id="current_password_for_email"
value={@email_form_current_password || ""}
class="input input-bordered w-full bg-base-100 text-base-content"
class={[
"input input-bordered w-full bg-base-100 text-base-content",
Keyword.has_key?(@email_changeset.errors, :current_password) && "input-error"
]}
placeholder="Enter current password"
required
/>
<label
:if={Keyword.has_key?(@email_changeset.errors, :current_password)}
class="label"
>
<span class="label-text-alt text-error">
{translate_error(Keyword.get(@email_changeset.errors, :current_password))}
</span>
</label>
</div>
<div class="form-control mt-6">
@ -68,6 +89,79 @@ defmodule AprsmeWeb.UserSettingsLive do
</div>
</div>
<!-- Change Callsign Section -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title text-2xl mb-4">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="alert alert-error mb-4">
<span>Oops, something went wrong! Please check the errors below.</span>
</div>
<div class="form-control w-full">
<label class="label">
<span class="label-text">Callsign</span>
</label>
<input
type="text"
name={Phoenix.HTML.Form.input_name(f, :callsign)}
value={Phoenix.HTML.Form.input_value(f, :callsign) || ""}
class={[
"input input-bordered w-full bg-base-100 text-base-content",
Keyword.has_key?(@callsign_changeset.errors, :callsign) && "input-error"
]}
placeholder="Enter your callsign"
required
/>
<label :if={Keyword.has_key?(@callsign_changeset.errors, :callsign)} class="label">
<span class="label-text-alt text-error">
{translate_error(Keyword.get(@callsign_changeset.errors, :callsign))}
</span>
</label>
</div>
<div class="form-control w-full">
<label class="label">
<span class="label-text">Current password</span>
</label>
<input
type="password"
name="current_password"
id="current_password_for_callsign"
value={@callsign_form_current_password || ""}
class={[
"input input-bordered w-full bg-base-100 text-base-content",
Keyword.has_key?(@callsign_changeset.errors, :current_password) && "input-error"
]}
placeholder="Enter current password"
required
/>
<label
:if={Keyword.has_key?(@callsign_changeset.errors, :current_password)}
class="label"
>
<span class="label-text-alt text-error">
{translate_error(Keyword.get(@callsign_changeset.errors, :current_password))}
</span>
</label>
</div>
<div class="form-control mt-6">
<button type="submit" class="btn btn-primary w-full" phx-disable-with="Changing...">
Change Callsign
</button>
</div>
</.simple_form>
</div>
</div>
<!-- Change Password Section -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
@ -101,10 +195,18 @@ defmodule AprsmeWeb.UserSettingsLive do
type="password"
name={Phoenix.HTML.Form.input_name(f, :password)}
value={Phoenix.HTML.Form.input_value(f, :password) || ""}
class="input input-bordered w-full bg-base-100 text-base-content"
class={[
"input input-bordered w-full bg-base-100 text-base-content",
Keyword.has_key?(@password_changeset.errors, :password) && "input-error"
]}
placeholder="Enter new password"
required
/>
<label :if={Keyword.has_key?(@password_changeset.errors, :password)} class="label">
<span class="label-text-alt text-error">
{translate_error(Keyword.get(@password_changeset.errors, :password))}
</span>
</label>
</div>
<div class="form-control w-full">
@ -115,10 +217,24 @@ defmodule AprsmeWeb.UserSettingsLive do
type="password"
name={Phoenix.HTML.Form.input_name(f, :password_confirmation)}
value={Phoenix.HTML.Form.input_value(f, :password_confirmation) || ""}
class="input input-bordered w-full bg-base-100 text-base-content"
class={[
"input input-bordered w-full bg-base-100 text-base-content",
Keyword.has_key?(@password_changeset.errors, :password_confirmation) &&
"input-error"
]}
placeholder="Confirm new password"
required
/>
<label
:if={Keyword.has_key?(@password_changeset.errors, :password_confirmation)}
class="label"
>
<span class="label-text-alt text-error">
{translate_error(
Keyword.get(@password_changeset.errors, :password_confirmation)
)}
</span>
</label>
</div>
<div class="form-control w-full">
@ -130,10 +246,21 @@ defmodule AprsmeWeb.UserSettingsLive do
name="current_password"
id="current_password_for_password"
value={@current_password || ""}
class="input input-bordered w-full bg-base-100 text-base-content"
class={[
"input input-bordered w-full bg-base-100 text-base-content",
Keyword.has_key?(@password_changeset.errors, :current_password) && "input-error"
]}
placeholder="Enter current password"
required
/>
<label
:if={Keyword.has_key?(@password_changeset.errors, :current_password)}
class="label"
>
<span class="label-text-alt text-error">
{translate_error(Keyword.get(@password_changeset.errors, :current_password))}
</span>
</label>
</div>
<div class="form-control mt-6">
@ -170,8 +297,11 @@ defmodule AprsmeWeb.UserSettingsLive do
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)
@ -211,6 +341,33 @@ defmodule AprsmeWeb.UserSettingsLive do
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)

View file

@ -0,0 +1,11 @@
defmodule Aprsme.Repo.Migrations.AddCallsignToUsers do
use Ecto.Migration
def change do
alter table(:users) do
add :callsign, :string, null: false
end
create unique_index(:users, [:callsign])
end
end

View file

@ -0,0 +1,96 @@
defmodule Aprsme.Accounts.UserTest do
use Aprsme.DataCase, async: true
alias Aprsme.Accounts.User
describe "registration_changeset/2" do
test "requires callsign" do
changeset = User.registration_changeset(%User{}, %{})
errors = errors_on(changeset)
assert Map.has_key?(errors, :callsign)
assert "can't be blank" in errors.callsign
end
test "validates callsign format" do
invalid_callsigns = ["123", "TOOLONGCALLSIGN", "NO SPACES", "nodigit"]
for callsign <- invalid_callsigns do
changeset =
User.registration_changeset(%User{}, %{
email: "test@example.com",
password: "valid_password123",
callsign: callsign
})
errors = errors_on(changeset)
assert Map.has_key?(errors, :callsign), "Expected callsign error for: #{callsign}"
assert "must be a valid amateur radio callsign" in errors.callsign
end
end
test "validates empty callsign" do
changeset =
User.registration_changeset(%User{}, %{
email: "test@example.com",
password: "valid_password123",
callsign: ""
})
errors = errors_on(changeset)
assert Map.has_key?(errors, :callsign)
assert "can't be blank" in errors.callsign
end
test "accepts valid callsigns" do
valid_callsigns = ["K1ABC", "W2XYZ", "AA3BB", "KG4AAA", "N7PQR", "VE3ABC"]
for callsign <- valid_callsigns do
changeset =
User.registration_changeset(%User{}, %{
email: "test@example.com",
password: "valid_password123",
callsign: callsign
})
assert changeset.valid?
end
end
test "converts callsign to uppercase" do
changeset =
User.registration_changeset(%User{}, %{
email: "test@example.com",
password: "valid_password123",
callsign: "k1abc"
})
assert get_change(changeset, :callsign) == "K1ABC"
end
end
describe "callsign_changeset/2" do
test "requires callsign to change" do
user = %User{callsign: "K1ABC"}
changeset = User.callsign_changeset(user, %{callsign: "K1ABC"})
errors = errors_on(changeset)
assert Map.has_key?(errors, :callsign)
assert "did not change" in errors.callsign
end
test "validates new callsign format" do
user = %User{callsign: "K1ABC"}
changeset = User.callsign_changeset(user, %{callsign: "INVALID"})
errors = errors_on(changeset)
assert Map.has_key?(errors, :callsign)
assert "must be a valid amateur radio callsign" in errors.callsign
end
test "accepts valid callsign change" do
user = %User{callsign: "K1ABC"}
changeset = User.callsign_changeset(user, %{callsign: "W2XYZ"})
assert changeset.valid?
assert get_change(changeset, :callsign) == "W2XYZ"
end
end
end

View file

@ -28,11 +28,12 @@ defmodule AprsmeWeb.UserRegistrationLiveTest do
result =
lv
|> element("#registration_form")
|> render_change(user: %{"email" => "with spaces", "password" => "too short"})
|> render_change(user: %{"email" => "with spaces", "password" => "too short", "callsign" => "INVALID"})
assert result =~ "Register"
assert result =~ "must have the @ sign and no spaces"
assert result =~ "should be at least 12 character"
assert result =~ "must be a valid amateur radio callsign"
end
end

View file

@ -0,0 +1,183 @@
defmodule AprsmeWeb.UserSettingsLiveTest do
use AprsmeWeb.ConnCase
import Aprsme.AccountsFixtures
import Phoenix.LiveViewTest
alias Aprsme.Accounts
describe "Settings page" do
setup %{conn: conn} do
user = user_fixture()
conn = log_in_user(conn, user)
{:ok, conn: conn, user: user}
end
test "renders settings page", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/users/settings")
assert html =~ "Change Email"
assert html =~ "Change Callsign"
assert html =~ "Change Password"
end
test "redirects if user is not logged in" do
conn = build_conn()
{:error, redirect} = live(conn, ~p"/users/settings")
assert {:redirect, %{to: path, flash: flash}} = redirect
assert path == ~p"/users/log_in"
assert %{"error" => "You must log in to access this page."} = flash
end
end
describe "update callsign form" do
setup %{conn: conn} do
user = user_fixture()
conn = log_in_user(conn, user)
{:ok, conn: conn, user: user}
end
test "updates the user callsign", %{conn: conn, user: user} do
{:ok, lv, _html} = live(conn, ~p"/users/settings")
lv
|> form("#callsign_form", %{
"current_password" => valid_user_password(),
"user" => %{"callsign" => "W9NEW"}
})
|> render_submit()
# The form should redirect after success
flash = assert_redirect(lv, ~p"/users/settings")
assert flash["info"] == "Callsign updated successfully."
# Verify the callsign was updated
updated_user = Accounts.get_user!(user.id)
assert updated_user.callsign == "W9NEW"
end
test "renders errors with invalid callsign", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/users/settings")
result =
lv
|> form("#callsign_form", %{
"current_password" => valid_user_password(),
"user" => %{"callsign" => "INVALID"}
})
|> render_submit()
assert result =~ "must be a valid amateur radio callsign"
end
test "renders errors with invalid password", %{conn: conn, user: user} do
{:ok, lv, _html} = live(conn, ~p"/users/settings")
result =
lv
|> form("#callsign_form", %{
"current_password" => "invalid",
"user" => %{"callsign" => "W9NEW"}
})
|> render_submit()
assert result =~ "is not valid"
# Verify the callsign was not updated
updated_user = Accounts.get_user!(user.id)
assert updated_user.callsign == user.callsign
end
end
describe "update email form" do
setup %{conn: conn} do
user = user_fixture()
conn = log_in_user(conn, user)
{:ok, conn: conn, user: user}
end
test "updates the user email", %{conn: conn, user: user} do
{:ok, lv, _html} = live(conn, ~p"/users/settings")
result =
lv
|> form("#email_form", %{
"current_password" => valid_user_password(),
"user" => %{"email" => unique_user_email()}
})
|> render_submit()
assert result =~ "A link to confirm your email"
assert Accounts.get_user_by_email(user.email)
end
test "renders errors with invalid data (phx-change)", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/users/settings")
result =
lv
|> element("#email_form")
|> render_change(%{
"current_password" => valid_user_password(),
"user" => %{"email" => "with spaces"}
})
assert result =~ "must have the @ sign and no spaces"
end
end
describe "update password form" do
setup %{conn: conn} do
user = user_fixture()
conn = log_in_user(conn, user)
{:ok, conn: conn, user: user}
end
test "updates the user password", %{conn: conn, user: user} do
{:ok, lv, _html} = live(conn, ~p"/users/settings")
form =
form(lv, "#password_form", %{
"current_password" => valid_user_password(),
"user" => %{
"email" => user.email,
"password" => "new valid password",
"password_confirmation" => "new valid password"
}
})
render_submit(form)
new_password_conn = follow_trigger_action(form, conn)
assert redirected_to(new_password_conn) == ~p"/users/settings"
assert get_session(new_password_conn, :user_token) != get_session(conn, :user_token)
assert Phoenix.Flash.get(new_password_conn.assigns.flash, :info) =~
"Password updated successfully"
assert Accounts.get_user_by_email_and_password(user.email, "new valid password")
end
test "renders errors with invalid data (phx-change)", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/users/settings")
result =
lv
|> element("#password_form")
|> render_change(%{
"current_password" => valid_user_password(),
"user" => %{
"password" => "short",
"password_confirmation" => "does not match"
}
})
assert result =~ "should be at least 12 character"
assert result =~ "does not match password"
end
end
end

View file

@ -7,10 +7,16 @@ defmodule Aprsme.AccountsFixtures do
def unique_user_email, do: "user#{System.unique_integer()}@example.com"
def valid_user_password, do: "hello world!"
def unique_user_callsign do
num = rem(System.unique_integer([:positive]), 99) + 1
"K#{num}ABC"
end
def valid_user_attributes(attrs \\ %{}) do
Enum.into(attrs, %{
email: unique_user_email(),
password: valid_user_password()
password: valid_user_password(),
callsign: unique_user_callsign()
})
end