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:
parent
712dbf25dd
commit
f941e13f0d
4 changed files with 257 additions and 8 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue