@@ -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
/>
+
@@ -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
/>
+
@@ -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
/>
+
@@ -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)
diff --git a/priv/repo/migrations/20250710205402_add_callsign_to_users.exs b/priv/repo/migrations/20250710205402_add_callsign_to_users.exs
new file mode 100644
index 0000000..e388062
--- /dev/null
+++ b/priv/repo/migrations/20250710205402_add_callsign_to_users.exs
@@ -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
diff --git a/test/aprsme/accounts/user_test.exs b/test/aprsme/accounts/user_test.exs
new file mode 100644
index 0000000..ea5b6d2
--- /dev/null
+++ b/test/aprsme/accounts/user_test.exs
@@ -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
diff --git a/test/aprsme_web/live/user_registration_live_test.exs b/test/aprsme_web/live/user_registration_live_test.exs
index 1141fa1..8ce7c7d 100644
--- a/test/aprsme_web/live/user_registration_live_test.exs
+++ b/test/aprsme_web/live/user_registration_live_test.exs
@@ -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
diff --git a/test/aprsme_web/live/user_settings_live_test.exs b/test/aprsme_web/live/user_settings_live_test.exs
new file mode 100644
index 0000000..1f144d7
--- /dev/null
+++ b/test/aprsme_web/live/user_settings_live_test.exs
@@ -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
diff --git a/test/support/fixtures/accounts_fixtures.ex b/test/support/fixtures/accounts_fixtures.ex
index 637600d..4c6921b 100644
--- a/test/support/fixtures/accounts_fixtures.ex
+++ b/test/support/fixtures/accounts_fixtures.ex
@@ -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