add user profile settings with name, avatar, and timezone

- Add migration for name, avatar_url, timezone fields
- Update User schema with profile_changeset
- Add Accounts context functions for profile updates
- Reorganize settings page into sections:
  * Personal Information (name, avatar, timezone)
  * Account (email, password)
  * API (tokens)
  * Notifications (mobile devices)
  * Security (passkeys)
- Avatar URL accepts external URLs (Gravatar, etc)
- Timezone selector with common zones
This commit is contained in:
Graham McIntire 2026-01-18 10:32:23 -06:00
parent 712dbf25dd
commit f941e13f0d
No known key found for this signature in database
4 changed files with 257 additions and 8 deletions

View file

@ -139,6 +139,37 @@ defmodule Towerops.Accounts do
## Settings
@doc """
Returns an `%Ecto.Changeset{}` for changing the user profile.
## Examples
iex> change_user_profile(user)
%Ecto.Changeset{data: %User{}}
"""
def change_user_profile(user, attrs \\ %{}) do
User.profile_changeset(user, attrs)
end
@doc """
Updates the user profile.
## Examples
iex> update_user_profile(user, %{name: ...})
{:ok, %User{}}
iex> update_user_profile(user, %{name: bad_value})
{:error, %Ecto.Changeset{}}
"""
def update_user_profile(user, attrs) do
user
|> User.profile_changeset(attrs)
|> Repo.update()
end
@doc """
Checks whether the user is in sudo mode.

View file

@ -24,6 +24,9 @@ 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 :avatar_url, :string
field :timezone, :string
has_many :memberships, Membership
has_many :organizations, through: [:memberships, :organization]
@ -40,6 +43,9 @@ defmodule Towerops.Accounts.User do
confirmed_at: DateTime.t() | nil,
authenticated_at: DateTime.t() | nil,
is_superuser: boolean(),
name: String.t() | nil,
avatar_url: String.t() | nil,
timezone: String.t(),
memberships: NotLoaded.t() | [Membership.t()],
organizations: NotLoaded.t() | [Towerops.Organizations.Organization.t()],
credentials: NotLoaded.t() | [UserCredential.t()],
@ -89,6 +95,17 @@ defmodule Towerops.Accounts.User do
end
end
@doc """
A user changeset for updating the profile information (name, avatar_url, timezone).
"""
def profile_changeset(user, attrs) do
user
|> cast(attrs, [:name, :avatar_url, :timezone])
|> validate_length(:name, max: 255)
|> validate_length(:avatar_url, max: 500)
|> validate_length(:timezone, max: 100)
end
@doc """
A user changeset for changing the password.

View file

@ -15,6 +15,7 @@ defmodule ToweropsWeb.UserSettingsLive do
socket
|> assign(:page_title, "Account Settings")
|> assign_changesets()
|> assign_profile_form()
|> assign_credentials()
|> assign_mobile_sessions()
|> assign_api_tokens()
@ -26,6 +27,24 @@ defmodule ToweropsWeb.UserSettingsLive do
{:ok, socket}
end
@impl true
def handle_event("update_profile", %{"user" => user_params}, socket) do
user = socket.assigns.current_scope.user
case Accounts.update_user_profile(user, user_params) do
{:ok, _updated_user} ->
socket =
socket
|> put_flash(:info, "Profile updated successfully.")
|> assign_profile_form()
{:noreply, socket}
{:error, changeset} ->
{:noreply, assign(socket, :profile_form, to_form(changeset))}
end
end
@impl true
def handle_event("update_email", %{"user" => user_params}, socket) do
user = socket.assigns.current_scope.user
@ -162,6 +181,11 @@ defmodule ToweropsWeb.UserSettingsLive do
end
end
defp assign_profile_form(socket) do
user = socket.assigns.current_scope.user
assign(socket, :profile_form, user |> Accounts.change_user_profile() |> to_form())
end
defp assign_changesets(socket) do
user = socket.assigns.current_scope.user
@ -217,10 +241,172 @@ defmodule ToweropsWeb.UserSettingsLive do
</div>
<div class="divide-y divide-gray-200 dark:divide-white/10">
<!-- Email Address Section -->
<!-- Personal Information Section -->
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-10 px-4 py-16 sm:px-6 md:grid-cols-3 lg:px-8">
<div>
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">Email Address</h2>
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
Personal Information
</h2>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
Update your name, avatar, and timezone preferences.
</p>
</div>
<.form
for={@profile_form}
phx-submit="update_profile"
id="update_profile"
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
</label>
<div class="mt-2">
<input
type="text"
name={@profile_form[:name].name}
id="name"
value={@profile_form[:name].value}
autocomplete="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="col-span-full">
<label
for="avatar-url"
class="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Avatar URL
</label>
<div class="mt-2">
<input
type="url"
name={@profile_form[:avatar_url].name}
id="avatar-url"
value={@profile_form[:avatar_url].value}
placeholder="https://example.com/avatar.jpg"
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>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Enter a URL to an avatar image or use a service like Gravatar.
</p>
</div>
<div class="col-span-full">
<label
for="timezone"
class="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Timezone
</label>
<div class="mt-2 grid grid-cols-1">
<select
name={@profile_form[:timezone].name}
id="timezone"
class="col-start-1 row-start-1 w-full appearance-none rounded-md bg-white py-1.5 pr-8 pl-3 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:*:bg-gray-800 dark:focus:outline-indigo-500"
>
<option value="UTC" selected={@profile_form[:timezone].value == "UTC"}>
UTC (Coordinated Universal Time)
</option>
<option
value="America/New_York"
selected={@profile_form[:timezone].value == "America/New_York"}
>
Eastern Time (US & Canada)
</option>
<option
value="America/Chicago"
selected={@profile_form[:timezone].value == "America/Chicago"}
>
Central Time (US & Canada)
</option>
<option
value="America/Denver"
selected={@profile_form[:timezone].value == "America/Denver"}
>
Mountain Time (US & Canada)
</option>
<option
value="America/Los_Angeles"
selected={@profile_form[:timezone].value == "America/Los_Angeles"}
>
Pacific Time (US & Canada)
</option>
<option
value="Europe/London"
selected={@profile_form[:timezone].value == "Europe/London"}
>
London
</option>
<option
value="Europe/Paris"
selected={@profile_form[:timezone].value == "Europe/Paris"}
>
Paris
</option>
<option
value="Europe/Berlin"
selected={@profile_form[:timezone].value == "Europe/Berlin"}
>
Berlin
</option>
<option
value="Asia/Tokyo"
selected={@profile_form[:timezone].value == "Asia/Tokyo"}
>
Tokyo
</option>
<option
value="Asia/Shanghai"
selected={@profile_form[:timezone].value == "Asia/Shanghai"}
>
Shanghai
</option>
<option
value="Australia/Sydney"
selected={@profile_form[:timezone].value == "Australia/Sydney"}
>
Sydney
</option>
</select>
<svg
viewBox="0 0 16 16"
fill="currentColor"
data-slot="icon"
aria-hidden="true"
class="pointer-events-none col-start-1 row-start-1 mr-2 size-5 self-center justify-self-end text-gray-400 sm:size-4"
>
<path
d="M4.22 6.22a.75.75 0 0 1 1.06 0L8 8.94l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.22 7.28a.75.75 0 0 1 0-1.06Z"
clip-rule="evenodd"
fill-rule="evenodd"
/>
</svg>
</div>
</div>
</div>
<div class="mt-8 flex">
<button
type="submit"
phx-disable-with="Saving..."
class="rounded-md bg-indigo-600 px-3 py-2 text-sm 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:shadow-none dark:hover:bg-indigo-400 dark:focus-visible:outline-indigo-500"
>
Save
</button>
</div>
</.form>
</div>
<!-- Account - Email Address Section -->
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-10 px-4 py-16 sm:px-6 md:grid-cols-3 lg:px-8">
<div>
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">Account - Email</h2>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
Update your email address. You'll receive a confirmation link to verify the new address.
</p>
@ -263,10 +449,12 @@ defmodule ToweropsWeb.UserSettingsLive do
</.form>
</div>
<!-- Change Password Section -->
<!-- Account - Password Section -->
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-10 px-4 py-16 sm:px-6 md:grid-cols-3 lg:px-8">
<div>
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">Change Password</h2>
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
Account - Password
</h2>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
Update your password. You'll be logged out and need to sign in again with your new password.
</p>
@ -408,11 +596,11 @@ defmodule ToweropsWeb.UserSettingsLive do
</div>
</div>
<!-- Mobile Devices Section -->
<!-- Notifications - Mobile Devices Section -->
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-10 px-4 py-16 sm:px-6 md:grid-cols-3 lg:px-8">
<div>
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
Alert Notification Devices
Notifications - Mobile Devices
</h2>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
Manage mobile devices that receive push notifications for alerts.
@ -501,10 +689,12 @@ defmodule ToweropsWeb.UserSettingsLive do
</div>
</div>
<!-- Passkeys Section -->
<!-- Security - Passkeys Section -->
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-10 px-4 py-16 sm:px-6 md:grid-cols-3 lg:px-8">
<div>
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">Passkeys</h2>
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
Security - Passkeys
</h2>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
Use your device's biometrics (Face ID, Touch ID, Windows Hello) or security keys to sign in.
</p>

View file

@ -0,0 +1,11 @@
defmodule Towerops.Repo.Migrations.AddProfileFieldsToUsers do
use Ecto.Migration
def change do
alter table(:users) do
add :name, :string
add :avatar_url, :string
add :timezone, :string, default: "UTC"
end
end
end