Merge branch 'gmcintire/user-first-last-name' into 'main'

Replace single name field with first_name and last_name

See merge request towerops/towerops!3
This commit is contained in:
Graham McIntire 2026-02-01 15:00:24 -06:00
commit a66aec675e
7 changed files with 225 additions and 18 deletions

View file

@ -23,7 +23,8 @@ defmodule Towerops.Accounts.User do
field :confirmed_at, :utc_datetime
field :authenticated_at, :utc_datetime, virtual: true
field :is_superuser, :boolean, default: false
field :name, :string
field :first_name, :string
field :last_name, :string
field :timezone, :string, default: "UTC"
field :totp_secret, :binary, redact: true
field :totp_verified_at, :utc_datetime, virtual: true
@ -46,7 +47,8 @@ defmodule Towerops.Accounts.User do
confirmed_at: DateTime.t() | nil,
authenticated_at: DateTime.t() | nil,
is_superuser: boolean(),
name: String.t() | nil,
first_name: String.t() | nil,
last_name: String.t() | nil,
timezone: String.t(),
totp_secret: binary() | nil,
totp_verified_at: DateTime.t() | nil,
@ -100,12 +102,13 @@ defmodule Towerops.Accounts.User do
end
@doc """
A user changeset for updating the profile information (name, timezone).
A user changeset for updating the profile information (first_name, last_name, timezone).
"""
def profile_changeset(user, attrs) do
user
|> cast(attrs, [:name, :timezone])
|> validate_length(:name, max: 255)
|> cast(attrs, [:first_name, :last_name, :timezone])
|> validate_length(:first_name, max: 100)
|> validate_length(:last_name, max: 100)
|> validate_length(:timezone, max: 100)
end
@ -124,11 +127,13 @@ defmodule Towerops.Accounts.User do
"""
def registration_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:email, :password, :timezone, :privacy_policy_consent, :terms_of_service_consent])
|> cast(attrs, [:email, :password, :first_name, :last_name, :timezone, :privacy_policy_consent, :terms_of_service_consent])
|> validate_email_for_registration(opts)
|> validate_password(opts)
|> validate_consent()
|> validate_timezone()
|> validate_length(:first_name, max: 100)
|> validate_length(:last_name, max: 100)
end
defp validate_consent(changeset) do
@ -255,4 +260,19 @@ defmodule Towerops.Accounts.User do
Argon2.no_user_verify()
false
end
@doc """
Returns the user's full name by combining first_name and last_name.
Returns nil if both fields are nil or empty.
"""
@spec full_name(t()) :: String.t() | nil
def full_name(%__MODULE__{first_name: first, last_name: last}) do
[first, last]
|> Enum.reject(&(is_nil(&1) or &1 == ""))
|> case do
[] -> nil
parts -> Enum.join(parts, " ")
end
end
end

View file

@ -47,7 +47,8 @@ defmodule ToweropsWeb.Api.AccountDataController do
%{
id: user.id,
email: user.email,
name: user.name,
first_name: user.first_name,
last_name: user.last_name,
timezone: user.timezone,
is_superuser: user.is_superuser,
confirmed_at: user.confirmed_at,

View file

@ -166,9 +166,15 @@ defmodule ToweropsWeb.AccountLive.MyData do
<dd class="mt-1 text-sm text-gray-900 dark:text-white">{@user_data.profile.email}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Name</dt>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">First Name</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
{@user_data.profile.name || "Not set"}
{@user_data.profile.first_name || "Not set"}
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Last Name</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
{@user_data.profile.last_name || "Not set"}
</dd>
</div>
<div>
@ -564,7 +570,8 @@ defmodule ToweropsWeb.AccountLive.MyData do
defp get_user_profile(user) do
%{
email: user.email,
name: user.name,
first_name: user.first_name,
last_name: user.last_name,
timezone: user.timezone,
inserted_at: user.inserted_at,
confirmed_at: user.confirmed_at

View file

@ -167,13 +167,27 @@ defmodule ToweropsWeb.UserRegistrationLive do
<input type="hidden" name="user[invitation_token]" value={@invitation_token} />
<% end %>
<div class="grid grid-cols-2 gap-4">
<.input
field={@form[:first_name]}
type="text"
label="First name"
autocomplete="given-name"
phx-mounted={JS.focus()}
/>
<.input
field={@form[:last_name]}
type="text"
label="Last name"
autocomplete="family-name"
/>
</div>
<.input
field={@form[:email]}
type="email"
label="Email"
autocomplete="email"
required
phx-mounted={JS.focus()}
/>
<.input
field={@form[:password]}

View file

@ -759,17 +759,39 @@ defmodule ToweropsWeb.UserSettingsLive do
class="md:col-span-2"
>
<div class="grid grid-cols-1 gap-x-6 gap-y-8 sm:max-w-xl sm:grid-cols-6">
<div class="col-span-full">
<label for="name" class="block text-sm/6 font-medium text-gray-900 dark:text-white">
Name
<div class="sm:col-span-3">
<label
for="first_name"
class="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
First name
</label>
<div class="mt-2">
<input
type="text"
name={@profile_form[:name].name}
id="name"
value={@profile_form[:name].value}
autocomplete="name"
name={@profile_form[:first_name].name}
id="first_name"
value={@profile_form[:first_name].value}
autocomplete="given-name"
class="block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 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:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500"
/>
</div>
</div>
<div class="sm:col-span-3">
<label
for="last_name"
class="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Last name
</label>
<div class="mt-2">
<input
type="text"
name={@profile_form[:last_name].name}
id="last_name"
value={@profile_form[:last_name].value}
autocomplete="family-name"
class="block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 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:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500"
/>
</div>

View file

@ -0,0 +1,17 @@
defmodule Towerops.Repo.Migrations.AddFirstLastNameToUsers do
use Ecto.Migration
def change do
alter table(:users) do
add :first_name, :string
add :last_name, :string
end
# Migrate existing name data to first_name
execute "UPDATE users SET first_name = name WHERE name IS NOT NULL", ""
alter table(:users) do
remove :name, :string
end
end
end

View file

@ -0,0 +1,126 @@
defmodule Towerops.Accounts.UserTest do
use Towerops.DataCase, async: true
alias Towerops.Accounts.User
describe "registration_changeset/3" do
test "accepts first_name and last_name" do
attrs = %{
email: "test#{System.unique_integer()}@example.com",
password: "valid_password123",
first_name: "John",
last_name: "Smith",
privacy_policy_consent: true,
terms_of_service_consent: true
}
changeset = User.registration_changeset(%User{}, attrs)
assert changeset.valid?, "Changeset errors: #{inspect(changeset.errors)}"
assert Ecto.Changeset.get_field(changeset, :first_name) == "John"
assert Ecto.Changeset.get_field(changeset, :last_name) == "Smith"
end
test "validates first_name length" do
attrs = %{
email: "test#{System.unique_integer()}@example.com",
password: "valid_password123",
first_name: String.duplicate("a", 256),
last_name: "Smith",
privacy_policy_consent: true,
terms_of_service_consent: true
}
changeset = User.registration_changeset(%User{}, attrs)
refute changeset.valid?
assert "should be at most 100 character(s)" in errors_on(changeset).first_name
end
test "validates last_name length" do
attrs = %{
email: "test#{System.unique_integer()}@example.com",
password: "valid_password123",
first_name: "John",
last_name: String.duplicate("a", 256),
privacy_policy_consent: true,
terms_of_service_consent: true
}
changeset = User.registration_changeset(%User{}, attrs)
refute changeset.valid?
assert "should be at most 100 character(s)" in errors_on(changeset).last_name
end
end
describe "profile_changeset/2" do
test "accepts first_name and last_name updates" do
user = %User{first_name: "John", last_name: "Smith"}
attrs = %{first_name: "Jane", last_name: "Doe"}
changeset = User.profile_changeset(user, attrs)
assert changeset.valid?
assert Ecto.Changeset.get_change(changeset, :first_name) == "Jane"
assert Ecto.Changeset.get_change(changeset, :last_name) == "Doe"
end
test "validates first_name length in profile update" do
user = %User{first_name: "John", last_name: "Smith"}
attrs = %{first_name: String.duplicate("a", 256)}
changeset = User.profile_changeset(user, attrs)
refute changeset.valid?
assert "should be at most 100 character(s)" in errors_on(changeset).first_name
end
test "validates last_name length in profile update" do
user = %User{first_name: "John", last_name: "Smith"}
attrs = %{last_name: String.duplicate("a", 256)}
changeset = User.profile_changeset(user, attrs)
refute changeset.valid?
assert "should be at most 100 character(s)" in errors_on(changeset).last_name
end
test "does not accept name field (deprecated)" do
user = %User{first_name: "John", last_name: "Smith"}
attrs = %{name: "Jane Doe"}
changeset = User.profile_changeset(user, attrs)
# name field should not be cast
assert Ecto.Changeset.get_change(changeset, :name) == nil
end
end
describe "full_name/1" do
test "returns full name when both first and last name are present" do
user = %User{first_name: "John", last_name: "Smith"}
assert User.full_name(user) == "John Smith"
end
test "returns first name when only first name is present" do
user = %User{first_name: "John", last_name: nil}
assert User.full_name(user) == "John"
end
test "returns last name when only last name is present" do
user = %User{first_name: nil, last_name: "Smith"}
assert User.full_name(user) == "Smith"
end
test "returns nil when neither name is present" do
user = %User{first_name: nil, last_name: nil}
assert User.full_name(user) == nil
end
test "handles empty strings as nil" do
user = %User{first_name: "", last_name: ""}
assert User.full_name(user) == nil
end
end
end