Packages
phoenix_kit
1.7.206
1.7.209
1.7.208
1.7.207
1.7.206
1.7.205
1.7.204
1.7.203
1.7.202
1.7.201
1.7.200
1.7.199
1.7.198
1.7.197
1.7.196
1.7.194
1.7.193
1.7.192
1.7.191
1.7.190
1.7.189
1.7.187
1.7.186
1.7.185
1.7.184
1.7.183
1.7.182
1.7.181
1.7.180
1.7.179
1.7.178
1.7.177
1.7.176
1.7.175
1.7.174
1.7.173
1.7.172
1.7.171
1.7.170
1.7.169
1.7.168
1.7.167
1.7.166
1.7.165
1.7.164
1.7.162
1.7.161
1.7.160
1.7.159
1.7.157
1.7.156
1.7.155
1.7.154
1.7.153
1.7.152
1.7.151
1.7.150
1.7.149
1.7.146
1.7.145
1.7.144
1.7.143
1.7.138
1.7.133
1.7.132
1.7.131
1.7.130
1.7.128
1.7.126
1.7.125
1.7.121
1.7.120
1.7.119
1.7.118
1.7.117
1.7.116
1.7.115
1.7.114
1.7.113
1.7.112
1.7.111
1.7.110
1.7.109
1.7.108
1.7.107
1.7.106
1.7.105
1.7.104
1.7.103
1.7.102
1.7.101
1.7.100
1.7.99
1.7.98
1.7.97
1.7.96
1.7.95
1.7.94
1.7.93
1.7.92
1.7.91
1.7.90
1.7.89
1.7.88
1.7.87
1.7.86
1.7.85
1.7.84
1.7.83
1.7.82
1.7.81
1.7.80
1.7.79
1.7.78
1.7.77
1.7.76
1.7.75
1.7.74
1.7.71
1.7.70
1.7.69
1.7.66
1.7.65
1.7.64
1.7.63
1.7.62
1.7.61
1.7.59
1.7.58
1.7.57
1.7.56
1.7.55
1.7.54
1.7.53
1.7.52
1.7.51
1.7.49
1.7.44
1.7.43
1.7.42
1.7.41
1.7.39
1.7.38
1.7.37
1.7.36
1.7.34
1.7.33
1.7.31
1.7.30
1.7.29
1.7.28
1.7.27
1.7.26
1.7.25
1.7.24
1.7.23
1.7.22
1.7.21
1.7.20
1.7.19
1.7.18
1.7.17
1.7.16
1.7.15
1.7.14
1.7.13
1.7.12
1.7.11
1.7.10
1.7.9
1.7.8
1.7.7
1.7.6
1.7.5
1.7.4
1.7.3
1.7.2
1.7.1
1.7.0
1.6.20
1.6.19
1.6.18
1.6.17
1.6.16
1.6.15
1.6.14
1.6.13
1.6.12
1.6.11
1.6.10
1.6.9
1.6.8
1.6.7
1.6.6
1.6.5
1.6.4
1.6.3
1.5.2
1.5.1
1.5.0
1.4.9
1.4.8
1.4.7
1.4.6
1.4.5
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.2
1.3.1
1.3.0
1.2.10
1.2.9
1.2.8
1.2.7
1.2.5
1.2.4
1.2.2
1.2.1
1.2.0
1.1.0
1.0.0
A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more
Current section
Files
Jump to
Current section
Files
lib/phoenix_kit_web/live/components/user_settings.ex
defmodule PhoenixKitWeb.Live.Components.UserSettings do
@moduledoc """
Reusable LiveComponent for user settings management.
Provides profile, email, password, and OAuth settings in a self-contained
component that can be embedded in any LiveView.
## Usage
<.live_component
module={PhoenixKitWeb.Live.Components.UserSettings}
id="user-settings"
user={@current_user}
/>
## Required assigns
* `user` — the current user struct
* `id` — unique component ID
## Optional assigns
* `sections` — list of sections to display: `:identity`, `:custom_fields`, `:email`, `:password`, `:oauth`, `:notifications`, `:sessions`
(default: all). `:profile` is accepted as a legacy alias that expands to `[:identity, :custom_fields]`
* `email_confirm_url_fn` — `(token -> url)` for email confirmation links
(default: `&Routes.url("/dashboard/settings/confirm-email/\#{&1}")`)
* `return_to` — where OAuth redirect returns to (default: `"/dashboard/settings"`)
* `current_session_token` — raw session token of the acting browser, used
by the `:sessions` section to mark the current device and to keep it
signed in on "sign out other sessions". Without it, no session is
flagged current and "sign out others" revokes every session.
## Parent notifications
Sends `{:phoenix_kit_user_updated, updated_user}` to the parent LiveView
when user data changes (profile, avatar, email, password).
"""
use PhoenixKitWeb, :live_component
require Logger
alias PhoenixKit.Notifications.Prefs, as: NotificationPrefs
alias PhoenixKit.Notifications.Types, as: NotificationTypes
alias PhoenixKit.Settings
alias PhoenixKit.Users.Auth
alias PhoenixKit.Users.CustomFields
alias PhoenixKit.Users.OAuth
alias PhoenixKit.Users.OAuthAvailability
alias PhoenixKit.Users.Sessions
alias PhoenixKit.Utils.Date, as: UtilsDate
alias PhoenixKit.Utils.Routes
@default_sections [
:identity,
:custom_fields,
:email,
:password,
:oauth,
:notifications,
:sessions
]
@impl true
def update(%{action: :set_avatar, file_uuid: file_uuid}, socket) do
user = socket.assigns.user
case Auth.update_user_fields(user, %{"avatar_file_uuid" => file_uuid}) do
{:ok, updated_user} ->
send(self(), {:phoenix_kit_user_updated, updated_user})
PhoenixKit.Activity.log(%{
action: "user.avatar_changed",
module: "users",
mode: "manual",
actor_uuid: updated_user.uuid,
resource_type: "user",
resource_uuid: updated_user.uuid,
metadata: %{
"avatar_from" => get_in(user.custom_fields, ["avatar_file_uuid"]) || "",
"avatar_to" => file_uuid,
"actor_role" => "user"
}
})
{:ok,
socket
|> assign(:user, updated_user)
|> assign(:show_avatar_selector, false)
|> assign(:last_uploaded_avatar_uuid, file_uuid)
|> assign(:avatar_success_message, gettext("Avatar updated successfully!"))
|> assign(:avatar_error_message, nil)}
{:error, _changeset} ->
{:ok,
socket
|> assign(:show_avatar_selector, false)
|> assign(:avatar_error_message, gettext("Failed to update avatar"))
|> assign(:avatar_success_message, nil)}
end
end
def update(%{action: :avatar_selector_closed}, socket) do
{:ok, assign(socket, :show_avatar_selector, false)}
end
def update(assigns, socket) do
user = assigns[:user] || socket.assigns[:user]
sections = assigns[:sections] || socket.assigns[:sections] || @default_sections
# Expand legacy :profile into fine-grained sections for backward compatibility
sections =
Enum.flat_map(sections, fn
:profile -> [:identity, :custom_fields]
other -> [other]
end)
email_confirm_url_fn =
assigns[:email_confirm_url_fn] || socket.assigns[:email_confirm_url_fn] ||
(&Routes.url("/dashboard/settings/confirm-email/#{&1}"))
return_to = assigns[:return_to] || socket.assigns[:return_to] || "/dashboard/settings"
socket =
socket
|> assign(:id, assigns.id)
|> assign(:user, user)
|> assign(:sections, sections)
|> assign(:email_confirm_url_fn, email_confirm_url_fn)
|> assign(:return_to, return_to)
|> assign_new(:profile_success_message, fn -> nil end)
|> assign_new(:email_success_message, fn -> assigns[:email_success_message] end)
|> assign_new(:email_error_message, fn -> assigns[:email_error_message] end)
|> assign_new(:password_success_message, fn -> nil end)
|> assign_new(:password_error_message, fn -> nil end)
|> assign_new(:oauth_success_message, fn -> nil end)
|> assign_new(:oauth_error_message, fn -> nil end)
|> assign_new(:avatar_success_message, fn -> nil end)
|> assign_new(:avatar_error_message, fn -> nil end)
|> assign_new(:current_password, fn -> nil end)
|> assign_new(:email_form_current_password, fn -> nil end)
|> assign_new(:current_email, fn -> user.email end)
|> assign_new(:email_form, fn -> to_form(Auth.change_user_email(user)) end)
|> assign_new(:password_form, fn -> to_form(Auth.change_user_password(user)) end)
|> assign_new(:profile_form, fn -> to_form(Auth.change_user_profile(user)) end)
|> assign_new(:timezone_options, fn ->
[{"Use System Default", nil} | Settings.timezone_options()]
end)
|> assign_new(:browser_timezone_name, fn -> nil end)
|> assign_new(:browser_timezone_offset, fn -> nil end)
|> assign_new(:timezone_mismatch_warning, fn -> nil end)
|> assign_new(:trigger_submit, fn -> false end)
|> assign_new(:oauth_providers, fn -> OAuth.get_user_oauth_providers(user.uuid) end)
|> assign_new(:oauth_available, fn -> OAuthAvailability.oauth_available?() end)
|> assign_new(:available_providers, fn ->
oauth_providers = OAuth.get_user_oauth_providers(user.uuid)
get_available_oauth_providers(oauth_providers)
end)
|> assign_new(:custom_field_definitions, fn ->
CustomFields.list_user_accessible_field_definitions()
end)
|> assign_new(:last_uploaded_avatar_uuid, fn -> nil end)
|> assign_new(:show_avatar_selector, fn -> false end)
|> assign_new(:show_email_form, fn -> false end)
|> assign_new(:show_password_form, fn -> false end)
|> assign_new(:notification_types, fn -> NotificationTypes.list() end)
|> assign_new(:notification_prefs, fn -> NotificationPrefs.get(user) end)
|> assign_new(:notification_success_message, fn -> nil end)
|> assign_new(:current_session_token, fn -> assigns[:current_session_token] end)
|> assign_new(:session_success_message, fn -> nil end)
socket =
assign_new(socket, :sessions, fn ->
load_sessions(socket.assigns.user, socket.assigns.current_session_token)
end)
{:ok, socket}
end
# Event handlers
@impl true
def handle_event("validate_email", params, socket) do
%{"current_password" => password, "user" => user_params} = params
email_form =
socket.assigns.user
|> Auth.change_user_email(user_params)
|> Map.put(:action, :validate)
|> to_form()
{:noreply,
assign(socket,
email_form: email_form,
email_form_current_password: password,
email_success_message: nil,
email_error_message: nil
)}
end
def handle_event("update_email", params, socket) do
%{"current_password" => password, "user" => user_params} = params
user = socket.assigns.user
case Auth.apply_user_email(user, password, user_params) do
{:ok, applied_user} ->
Auth.deliver_user_update_email_instructions(
applied_user,
user.email,
socket.assigns.email_confirm_url_fn
)
socket =
socket
|> assign(
:email_success_message,
gettext("A link to confirm your email change has been sent to the new address.")
)
|> assign(email_form_current_password: nil)
{:noreply, socket}
{:error, changeset} ->
socket =
socket
|> assign(:email_form, to_form(Map.put(changeset, :action, :insert)))
|> assign(:email_success_message, nil)
|> assign(:email_error_message, nil)
{:noreply, socket}
end
end
def handle_event("validate_password", params, socket) do
%{"current_password" => password, "user" => user_params} = params
password_form =
socket.assigns.user
|> Auth.change_user_password(user_params)
|> Map.put(:action, :validate)
|> to_form()
{:noreply,
assign(socket,
password_form: password_form,
current_password: password,
password_success_message: nil,
password_error_message: nil
)}
end
def handle_event("update_password", params, socket) do
%{"current_password" => password, "user" => user_params} = params
user = socket.assigns.user
case Auth.update_user_password(user, password, user_params) do
{:ok, user} ->
password_form =
user
|> Auth.change_user_password(user_params)
|> to_form()
send(self(), {:phoenix_kit_user_updated, user})
socket =
socket
|> assign(trigger_submit: true, password_form: password_form)
|> assign(:password_success_message, gettext("Password changed successfully."))
{:noreply, socket}
{:error, changeset} ->
socket =
socket
|> assign(password_form: to_form(changeset))
|> assign(:password_success_message, nil)
{:noreply, socket}
end
end
def handle_event("validate_profile", params, socket) do
user_params =
case params do
%{"user" => user_params} -> user_params
%{"profile_form" => %{"user" => user_params}} -> user_params
_ -> %{}
end
socket =
case {params["browser_timezone_name"], params["browser_timezone_offset"]} do
{name, offset} when is_binary(name) and is_binary(offset) ->
socket
|> assign(:browser_timezone_name, name)
|> assign(:browser_timezone_offset, offset)
_ ->
socket
end
merged_params = merge_custom_fields(params, user_params)
profile_form =
socket.assigns.user
|> Auth.change_user_profile(merged_params)
|> Map.put(:action, :validate)
|> to_form()
socket =
socket
|> assign(profile_form: profile_form)
|> assign(:profile_success_message, nil)
|> assign(:email_error_message, nil)
|> assign(:oauth_error_message, nil)
|> assign(:avatar_error_message, nil)
|> check_timezone_mismatch(user_params["user_timezone"])
{:noreply, socket}
end
def handle_event("update_profile", params, socket) do
user_params =
case params do
%{"user" => user_params} -> user_params
%{"profile_form" => %{"user" => user_params}} -> user_params
_ -> %{}
end
user = socket.assigns.user
merged_params = merge_custom_fields_for_save(params, user_params, user)
case Auth.update_user_profile(user, merged_params) do
{:ok, updated_user} ->
send(self(), {:phoenix_kit_user_updated, updated_user})
socket =
socket
|> assign(:user, updated_user)
|> assign(:profile_success_message, gettext("Profile updated successfully"))
{:noreply, socket}
{:error, changeset} ->
socket =
socket
|> assign(:profile_form, to_form(Map.put(changeset, :action, :insert)))
|> assign(:profile_success_message, nil)
{:noreply, socket}
end
end
def handle_event("use_browser_timezone", _params, socket) do
browser_offset = socket.assigns.browser_timezone_offset
if browser_offset do
user = socket.assigns.user
updated_attrs = %{"user_timezone" => browser_offset}
profile_form =
user
|> Auth.change_user_profile(updated_attrs)
|> to_form()
socket =
socket
|> assign(:profile_form, profile_form)
|> assign(:timezone_mismatch_warning, nil)
{:noreply, socket}
else
{:noreply, socket}
end
end
def handle_event("connect_oauth_provider", %{"provider" => provider}, socket) do
return_to = socket.assigns.return_to
oauth_url = Routes.url("/users/auth/#{provider}?return_to=#{return_to}")
socket =
socket
|> assign(:oauth_info_message, "Redirecting to #{format_provider_name(provider)}...")
|> redirect(external: oauth_url)
{:noreply, socket}
end
def handle_event("disconnect_oauth_provider", %{"provider" => provider}, socket) do
user = socket.assigns.user
if can_disconnect_provider?(user, provider) do
case OAuth.unlink_oauth_provider(user.uuid, provider) do
{:ok, _} ->
oauth_providers = OAuth.get_user_oauth_providers(user.uuid)
available_providers = get_available_oauth_providers(oauth_providers)
socket =
socket
|> assign(:oauth_providers, oauth_providers)
|> assign(:available_providers, available_providers)
|> assign(
:oauth_success_message,
gettext("%{provider} account disconnected successfully",
provider: format_provider_name(provider)
)
)
|> assign(:oauth_error_message, nil)
{:noreply, socket}
{:error, :not_found} ->
socket =
assign(socket, :oauth_error_message, gettext("Provider not found"))
|> assign(:oauth_success_message, nil)
{:noreply, socket}
{:error, _reason} ->
socket =
assign(
socket,
:oauth_error_message,
gettext("Failed to disconnect provider. Please try again.")
)
|> assign(:oauth_success_message, nil)
{:noreply, socket}
end
else
warning_message =
if user.hashed_password == nil do
gettext(
"Cannot disconnect %{provider}. This is your only sign-in method. Please set a password or connect another provider first.",
provider: format_provider_name(provider)
)
else
gettext(
"Cannot disconnect %{provider}. Please ensure you have at least one sign-in method available.",
provider: format_provider_name(provider)
)
end
socket =
assign(socket, :oauth_error_message, warning_message)
|> assign(:oauth_success_message, nil)
{:noreply, socket}
end
end
def handle_event("open_avatar_selector", _params, socket) do
{:noreply, assign(socket, :show_avatar_selector, true)}
end
def handle_event("toggle_email_form", _params, socket) do
{:noreply, assign(socket, :show_email_form, not socket.assigns.show_email_form)}
end
def handle_event("toggle_password_form", _params, socket) do
{:noreply, assign(socket, :show_password_form, not socket.assigns.show_password_form)}
end
def handle_event("update_notification_prefs", params, socket) do
user = socket.assigns.user
# The form renders one hidden "false" + one checkbox "true" per type key
# under `params["notification_prefs"]`. Only the types registered right
# now are honored; any stray keys from the form are dropped so malformed
# submissions can't sneak data into custom_fields.
raw = params["notification_prefs"] || %{}
valid_keys = Enum.map(socket.assigns.notification_types, & &1.key)
prefs =
valid_keys
|> Enum.map(fn key -> {key, Map.get(raw, key) == "true"} end)
|> Map.new()
case NotificationPrefs.update(user, prefs) do
{:ok, updated_user} ->
send(self(), {:phoenix_kit_user_updated, updated_user})
{:noreply,
socket
|> assign(:user, updated_user)
|> assign(:notification_prefs, prefs)
|> assign(:notification_success_message, gettext("Notification preferences saved."))}
{:error, _changeset} ->
{:noreply,
assign(
socket,
:notification_success_message,
gettext("Failed to save notification preferences.")
)}
end
end
def handle_event("revoke_session", %{"uuid" => token_uuid}, socket) do
user = socket.assigns.user
message =
case Sessions.revoke_user_session(user, token_uuid) do
:ok -> gettext("Signed out of that session.")
{:error, :not_found} -> gettext("That session is no longer active.")
end
{:noreply,
socket
|> assign(:sessions, load_sessions(user, socket.assigns.current_session_token))
|> assign(:session_success_message, message)}
end
def handle_event("revoke_other_sessions", _params, socket) do
user = socket.assigns.user
Sessions.revoke_other_user_sessions(user, socket.assigns.current_session_token)
{:noreply,
socket
|> assign(:sessions, load_sessions(user, socket.assigns.current_session_token))
|> assign(:session_success_message, gettext("Signed out of all other sessions."))}
end
# Private helpers
defp load_sessions(user, current_token) do
Sessions.list_user_device_sessions(user, current_token)
end
defp device_label(%{browser: b, os: o}) when is_binary(b) and is_binary(o), do: "#{b} · #{o}"
defp device_label(%{browser: b}) when is_binary(b), do: b
defp device_label(%{os: o}) when is_binary(o), do: o
defp device_label(_), do: gettext("Unknown device")
defp session_meta_line(session) do
[session.location, session.ip_address, last_active_label(session)]
|> Enum.reject(&(is_nil(&1) or &1 == ""))
|> Enum.join(" · ")
end
defp last_active_label(%{is_current: true}), do: gettext("Active now")
defp last_active_label(%{last_active: nil}), do: nil
defp last_active_label(%{last_active: last_active}) do
time = UtilsDate.format_time_with_user_format(last_active)
case DateTime.diff(DateTime.utc_now(), last_active, :day) do
days when days <= 0 ->
gettext("Active today at %{time}", time: time)
1 ->
gettext("Active yesterday at %{time}", time: time)
_ ->
gettext("Active %{date} at %{time}",
date: UtilsDate.format_date_with_user_format(last_active),
time: time
)
end
end
defp check_timezone_mismatch(socket, selected_timezone) do
browser_offset = socket.assigns[:browser_timezone_offset]
browser_name = socket.assigns[:browser_timezone_name]
user_timezone =
selected_timezone ||
get_in(socket.assigns.profile_form.params, ["user_timezone"]) ||
socket.assigns.user.user_timezone
case {browser_offset, user_timezone} do
{nil, _} ->
assign(socket, :timezone_mismatch_warning, nil)
{browser_tz, nil} when browser_tz != "0" ->
system_tz = Settings.get_setting("time_zone", "0")
if browser_tz != system_tz do
warning_msg =
"Your browser timezone appears to be #{browser_name} (#{format_timezone_offset(browser_tz)}) " <>
"but you selected 'Use System Default' which is #{format_timezone_offset(system_tz)}."
assign(socket, :timezone_mismatch_warning, warning_msg)
else
assign(socket, :timezone_mismatch_warning, nil)
end
{browser_tz, user_tz} when browser_tz != user_tz ->
normalized_user_tz = String.replace(user_tz, "+", "")
normalized_browser_tz = String.replace(browser_tz, "+", "")
if normalized_browser_tz != normalized_user_tz do
warning_msg =
"Your browser timezone appears to be #{browser_name} (#{format_timezone_offset(browser_tz)}) " <>
"but you selected #{format_timezone_offset(user_tz)}. Please verify this is correct."
assign(socket, :timezone_mismatch_warning, warning_msg)
else
assign(socket, :timezone_mismatch_warning, nil)
end
_ ->
assign(socket, :timezone_mismatch_warning, nil)
end
end
defp format_timezone_offset(offset) do
case offset do
"0" ->
"UTC+0"
"+" <> _ ->
"UTC" <> offset
"-" <> _ ->
"UTC" <> offset
_ when is_binary(offset) ->
case Integer.parse(offset) do
{num, ""} when num > 0 -> "UTC+" <> offset
{num, ""} when num < 0 -> "UTC" <> offset
{0, ""} -> "UTC+0"
_ -> "UTC" <> offset
end
_ ->
"Unknown"
end
end
defp get_available_oauth_providers(oauth_providers) do
connected = Enum.map(oauth_providers, & &1.provider)
all_providers = ["google", "github"]
all_providers
|> Enum.reject(&(&1 in connected))
|> Enum.filter(&provider_enabled?/1)
end
defp provider_enabled?("google"), do: OAuthAvailability.provider_enabled?(:google)
defp provider_enabled?("github"), do: OAuthAvailability.provider_enabled?(:github)
defp provider_enabled?(_), do: false
defp can_disconnect_provider?(user, _provider) do
has_password = user.hashed_password != nil
oauth_count = length(OAuth.get_user_oauth_providers(user.uuid))
has_password or oauth_count > 1
end
defp extract_custom_fields(params) do
get_in(params, ["profile_form", "user", "custom_fields"])
end
defp merge_custom_fields(params, user_params) do
case extract_custom_fields(params) do
custom_fields when is_map(custom_fields) ->
Map.put(user_params, "custom_fields", custom_fields)
_ ->
user_params
end
end
# Merges form custom fields on top of all existing user custom fields for persistence.
# Ensures fields not present in the form (e.g. avatar_file_uuid, programmatic fields)
# are preserved when saving.
defp merge_custom_fields_for_save(params, user_params, user) do
existing = user.custom_fields || %{}
case extract_custom_fields(params) do
form_fields when is_map(form_fields) ->
Map.put(user_params, "custom_fields", Map.merge(existing, form_fields))
_ when map_size(existing) > 0 ->
Map.put(user_params, "custom_fields", existing)
_ ->
user_params
end
end
defp format_provider_name("google"), do: "Google"
defp format_provider_name("apple"), do: "Apple"
defp format_provider_name("github"), do: "GitHub"
defp format_provider_name(provider), do: String.capitalize(provider)
@impl Phoenix.LiveComponent
def render(assigns) do
~H"""
<div class="card bg-base-100 shadow-sm max-w-4xl mx-auto">
<div class="card-body">
<%!-- Identity Section (avatar, name, username, timezone) --%>
<%= if :identity in @sections do %>
<div>
<%!-- Success Message --%>
<%= if @profile_success_message do %>
<div class="alert alert-success text-sm mb-4">
<.icon name="hero-check" class="stroke-current shrink-0 h-4 w-4" />
<span>{@profile_success_message}</span>
</div>
<% end %>
<%!-- Avatar Upload Messages --%>
<%= if @last_uploaded_avatar_uuid do %>
<div class="alert alert-success text-sm mb-4">
<.icon name="hero-check" class="stroke-current shrink-0 h-4 w-4" />
<span>Avatar uploaded successfully!</span>
</div>
<% end %>
<%= if @avatar_error_message do %>
<div class="alert alert-error text-sm mb-4">
<.icon name="hero-exclamation-triangle" class="stroke-current shrink-0 h-4 w-4" />
<span>{@avatar_error_message}</span>
</div>
<% end %>
<%!-- Identity Form with Avatar --%>
<.simple_form
for={@profile_form}
id={"#{@id}-profile-form"}
phx-submit="update_profile"
phx-change="validate_profile"
phx-target={@myself}
>
<div class="flex flex-col gap-6 lg:flex-row lg:gap-4 lg:items-start">
<%!-- Avatar Section --%>
<div class="flex flex-col items-center gap-2 mx-auto lg:mx-0">
<%= if get_in(@user.custom_fields, ["avatar_file_uuid"]) do %>
<% avatar_url =
PhoenixKit.Modules.Storage.URLSigner.signed_url(
get_in(@user.custom_fields, ["avatar_file_uuid"]),
"thumbnail"
) %>
<img
src={avatar_url}
alt="Avatar"
class="w-40 h-40 rounded-full object-cover border-2 border-primary"
/>
<% else %>
<div class="w-40 h-40 rounded-full bg-primary/10 border-2 border-primary flex items-center justify-center">
<span class="text-5xl font-bold text-primary">
{String.upcase(String.at(@user.email, 0))}
</span>
</div>
<% end %>
<button
type="button"
phx-click="open_avatar_selector"
phx-target={@myself}
class="btn btn-primary w-40"
>
<.icon name="hero-photo" class="w-5 h-5" /> Browse Media
</button>
</div>
<%!-- Name Fields --%>
<div class="flex-1 grid grid-cols-1 lg:grid-cols-2 gap-3 w-full">
<.input
field={@profile_form[:first_name]}
type="text"
label="First Name"
/>
<.input
field={@profile_form[:last_name]}
type="text"
label="Last Name"
/>
<div class="col-span-1 lg:col-span-2">
<.input
field={@profile_form[:username]}
type="text"
label="Username"
/>
</div>
</div>
</div>
<%!-- Timezone Section --%>
<div id={"#{@id}-timezone-detector"}>
<.select
field={@profile_form[:user_timezone]}
label="Personal Timezone"
options={@timezone_options}
/>
<%= if assigns[:timezone_mismatch_warning] do %>
<div class="alert alert-warning text-sm mt-2">
<.icon
name="hero-exclamation-triangle"
class="stroke-current shrink-0 h-4 w-4"
/>
<div>
<div class="font-semibold">Timezone Mismatch Detected</div>
<div class="text-xs">
{@timezone_mismatch_warning}
</div>
</div>
</div>
<% end %>
<%= if assigns[:browser_timezone_name] do %>
<div class="text-xs text-base-content/60 mt-1">
Browser detected: {@browser_timezone_name} ({@browser_timezone_offset})
</div>
<% end %>
</div>
<:actions>
<div class="ml-auto">
<.button phx-disable-with="Updating..." class="btn-primary">
Update Profile
</.button>
</div>
</:actions>
</.simple_form>
<.live_component
module={PhoenixKitWeb.Live.Components.UserMediaSelectorModal}
id={"#{@id}-avatar-media-selector"}
show={@show_avatar_selector}
mode={:single}
selected_uuids={[]}
phoenix_kit_current_user={@user}
on_select={{PhoenixKitWeb.Live.Components.UserSettings, @id, :set_avatar}}
/>
<%= if Enum.any?([:custom_fields, :email, :password, :oauth], & &1 in @sections) do %>
<div class="divider"></div>
<% end %>
</div>
<% end %>
<%!-- Custom Fields Section --%>
<%= if :custom_fields in @sections and length(@custom_field_definitions) > 0 do %>
<div>
<.simple_form
for={@profile_form}
id={"#{@id}-custom-fields-form"}
phx-submit="update_profile"
phx-change="validate_profile"
phx-target={@myself}
>
<%= if :identity in @sections do %>
<div class="divider text-sm text-base-content/60">Additional Information</div>
<% else %>
<h2 class="text-lg font-semibold mb-2">Additional Information</h2>
<% end %>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
<%= for field <- @custom_field_definitions do %>
<% field_name = "profile_form[user][custom_fields][#{field["key"]}]" %>
<% field_value =
get_in(@user.custom_fields, [field["key"]]) || field["default"] || "" %>
<div class="col-span-1 lg:col-span-2">
<%= case field["type"] do %>
<% "select" -> %>
<.select
name={field_name}
label={field["label"]}
options={
Enum.map(field["options"] || [], fn opt ->
if is_binary(opt),
do: {opt, opt},
else: {opt["label"], opt["value"]}
end)
}
value={field_value}
required={field["required"]}
/>
<% "textarea" -> %>
<.textarea
name={field_name}
label={field["label"]}
value={field_value}
required={field["required"]}
/>
<% "number" -> %>
<.input
name={field_name}
type="number"
label={field["label"]}
value={field_value}
required={field["required"]}
/>
<% "email" -> %>
<.input
name={field_name}
type="email"
label={field["label"]}
value={field_value}
required={field["required"]}
/>
<% "url" -> %>
<.input
name={field_name}
type="url"
label={field["label"]}
value={field_value}
required={field["required"]}
/>
<% "date" -> %>
<.input
name={field_name}
type="date"
label={field["label"]}
value={field_value}
required={field["required"]}
/>
<% _ -> %>
<.input
name={field_name}
type="text"
label={field["label"]}
value={field_value}
required={field["required"]}
/>
<% end %>
</div>
<% end %>
</div>
<:actions>
<div class="ml-auto">
<.button phx-disable-with="Updating..." class="btn-primary">
Update Custom Fields
</.button>
</div>
</:actions>
</.simple_form>
<%= if Enum.any?([:email, :password, :oauth], & &1 in @sections) do %>
<div class="divider"></div>
<% end %>
</div>
<% end %>
<%!-- Email Section --%>
<%= if :email in @sections do %>
<div>
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold flex items-center gap-2">
<.icon name="hero-envelope" class="w-5 h-5 text-primary" /> Email Address
</h2>
<button
type="button"
phx-click="toggle_email_form"
phx-target={@myself}
class="btn btn-sm btn-outline"
>
<.icon
name={if @show_email_form, do: "hero-x-mark", else: "hero-pencil"}
class="w-4 h-4"
/>
{if @show_email_form, do: "Cancel", else: "Change Email"}
</button>
</div>
<div class="text-sm text-base-content/60 mb-4">{@current_email}</div>
<%= if @email_success_message do %>
<div class="alert alert-success text-sm mb-4">
<.icon name="hero-check" class="stroke-current shrink-0 h-4 w-4" />
<span>{@email_success_message}</span>
</div>
<% end %>
<%= if @email_error_message do %>
<div class="alert alert-error text-sm mb-4">
<.icon name="hero-exclamation-triangle" class="stroke-current shrink-0 h-4 w-4" />
<span>{@email_error_message}</span>
</div>
<% end %>
<%= if @show_email_form do %>
<.simple_form
for={@email_form}
id={"#{@id}-email-form"}
phx-submit="update_email"
phx-change="validate_email"
phx-target={@myself}
>
<.input
field={@email_form[:email]}
type="email"
label="New Email"
required
/>
<.input
field={@email_form[:current_password]}
name="current_password"
id={"#{@id}-current-password-for-email"}
type="password"
label="Current Password"
value={@email_form_current_password}
required
/>
<:actions>
<div class="ml-auto">
<.button phx-disable-with="Changing..." class="btn-primary">
Update Email
</.button>
</div>
</:actions>
</.simple_form>
<% end %>
</div>
<% end %>
<%= if :email in @sections and Enum.any?([:password, :oauth], & &1 in @sections) do %>
<div class="divider"></div>
<% end %>
<%!-- Password Section --%>
<%= if :password in @sections do %>
<div>
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold flex items-center gap-2">
<.icon name="hero-lock-closed" class="w-5 h-5 text-primary" /> Password
</h2>
<button
type="button"
phx-click="toggle_password_form"
phx-target={@myself}
class="btn btn-sm btn-outline"
>
<.icon
name={if @show_password_form, do: "hero-x-mark", else: "hero-pencil"}
class="w-4 h-4"
/>
{if @show_password_form, do: "Cancel", else: "Change Password"}
</button>
</div>
<div class="text-sm text-base-content/60 mb-4">••••••••</div>
<%= if @password_success_message do %>
<div class="alert alert-success text-sm mb-4">
<.icon name="hero-check" class="stroke-current shrink-0 h-4 w-4" />
<span>{@password_success_message}</span>
</div>
<% end %>
<%= if @password_error_message do %>
<div class="alert alert-error text-sm mb-4">
<.icon name="hero-exclamation-triangle" class="stroke-current shrink-0 h-4 w-4" />
<span>{@password_error_message}</span>
</div>
<% end %>
<%= if @show_password_form do %>
<.simple_form
for={@password_form}
id={"#{@id}-password-form"}
action={Routes.path("/users/log-in?_action=password_updated")}
method="post"
phx-change="validate_password"
phx-submit="update_password"
phx-trigger-action={@trigger_submit}
phx-target={@myself}
>
<input
name={@password_form[:email].name}
type="hidden"
id={"#{@id}-hidden-user-email"}
value={@current_email}
/>
<.input
field={@password_form[:password]}
type="password"
label="New Password"
required
/>
<.input
field={@password_form[:password_confirmation]}
type="password"
label="Confirm New Password"
/>
<.input
field={@password_form[:current_password]}
name="current_password"
type="password"
label="Current Password"
id={"#{@id}-current-password-for-password"}
value={@current_password}
required
/>
<:actions>
<div class="ml-auto">
<.button phx-disable-with="Changing..." class="btn-primary">
Update Password
</.button>
</div>
</:actions>
</.simple_form>
<% end %>
</div>
<% end %>
<%!-- OAuth Section --%>
<%= if :oauth in @sections and @oauth_available do %>
<div class="divider"></div>
<div>
<h2 class="text-lg font-semibold flex items-center gap-2 mb-4">
<.icon name="hero-link" class="w-5 h-5 text-primary" /> Connected Accounts
</h2>
<%= if @oauth_success_message do %>
<div class="alert alert-success text-sm mb-4">
<.icon name="hero-check" class="stroke-current shrink-0 h-4 w-4" />
<span>{@oauth_success_message}</span>
</div>
<% end %>
<%= if @oauth_error_message do %>
<div class="alert alert-error text-sm mb-4">
<.icon name="hero-exclamation-triangle" class="stroke-current shrink-0 h-4 w-4" />
<span>{@oauth_error_message}</span>
</div>
<% end %>
<%!-- Connected Providers --%>
<%= if length(@oauth_providers) > 0 do %>
<div class="space-y-2 mb-4">
<%= for provider <- @oauth_providers do %>
<div class="flex items-center justify-between p-3 bg-base-200 rounded-lg">
<div class="flex items-center gap-3">
<%= case provider.provider do %>
<% "google" -> %>
<.icon name="hero-globe-alt" class="w-5 h-5" />
<% "apple" -> %>
<.icon name="hero-device-phone-mobile" class="w-5 h-5" />
<% "github" -> %>
<.icon name="hero-code-bracket" class="w-5 h-5" />
<% _ -> %>
<.icon name="hero-link" class="w-5 h-5" />
<% end %>
<div>
<span class="font-medium">{format_provider_name(provider.provider)}</span>
<div class="text-xs text-base-content/60">
{provider.provider_email || @current_email}
</div>
</div>
</div>
<button
type="button"
phx-click="disconnect_oauth_provider"
phx-target={@myself}
phx-value-provider={provider.provider}
class="btn btn-sm btn-outline btn-error"
data-confirm="Are you sure you want to disconnect this account? You won't be able to sign in with it anymore."
>
Disconnect
</button>
</div>
<% end %>
</div>
<% end %>
<%!-- Available Providers --%>
<%= if length(@available_providers) > 0 do %>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<%= for provider <- @available_providers do %>
<button
type="button"
phx-click="connect_oauth_provider"
phx-target={@myself}
phx-value-provider={provider}
class="btn btn-outline"
>
<.icon name="hero-plus" class="w-4 h-4 mr-1" />
Connect {format_provider_name(provider)}
</button>
<% end %>
</div>
<% end %>
<%!-- Password Warning for OAuth-only Users --%>
<%= if length(@oauth_providers) > 0 and @user.hashed_password == nil do %>
<div class="alert alert-warning text-sm mt-4">
<.icon name="hero-exclamation-triangle" class="stroke-current shrink-0 h-4 w-4" />
<div>
<div class="font-semibold">No Password Set</div>
<div class="text-xs">
You signed up using OAuth. Consider setting a password above as a backup sign-in method.
</div>
</div>
</div>
<% end %>
</div>
<% end %>
<%!-- Notifications Section --%>
<%= if :notifications in @sections and @notification_types != [] do %>
<%= if Enum.any?([:identity, :custom_fields, :email, :password, :oauth], & &1 in @sections) do %>
<div class="divider"></div>
<% end %>
<div>
<h2 class="text-lg font-semibold flex items-center gap-2 mb-4">
<.icon name="hero-bell" class="w-5 h-5 text-primary" /> {gettext("Notifications")}
</h2>
<%= if @notification_success_message do %>
<div class="alert alert-success text-sm mb-4">
<.icon name="hero-check" class="stroke-current shrink-0 h-4 w-4" />
<span>{@notification_success_message}</span>
</div>
<% end %>
<p class="text-sm text-base-content/60 mb-4">
{gettext(
"Pick which notification types you want to receive. Unchecked types are muted — activities still record in the audit log but no bell notification is created for you."
)}
</p>
<form
phx-submit="update_notification_prefs"
phx-target={@myself}
class="space-y-3"
>
<%= for type <- @notification_types do %>
<% current =
case Map.get(@notification_prefs, type.key) do
true -> true
false -> false
_ -> type.default
end %>
<label class="flex items-start gap-3 p-3 rounded-lg border border-base-300 hover:bg-base-200/40 cursor-pointer transition-colors">
<input type="hidden" name={"notification_prefs[#{type.key}]"} value="false" />
<input
type="checkbox"
name={"notification_prefs[#{type.key}]"}
value="true"
checked={current}
class="checkbox checkbox-primary checkbox-sm mt-1"
/>
<div class="flex-1 min-w-0">
<div class="font-medium text-sm">{type.label}</div>
<%= if type.description && type.description != "" do %>
<div class="text-xs text-base-content/60 mt-0.5">{type.description}</div>
<% end %>
</div>
</label>
<% end %>
<div class="flex justify-end pt-2">
<button type="submit" class="btn btn-primary btn-sm">
{gettext("Save preferences")}
</button>
</div>
</form>
</div>
<% end %>
<%!-- Active Sessions Section --%>
<%= if :sessions in @sections do %>
<%= if Enum.any?([:identity, :custom_fields, :email, :password, :oauth, :notifications], & &1 in @sections) do %>
<div class="divider"></div>
<% end %>
<div>
<h2 class="text-lg font-semibold flex items-center gap-2 mb-4">
<.icon name="hero-computer-desktop" class="w-5 h-5 text-primary" />
{gettext("Active Sessions")}
</h2>
<%= if @session_success_message do %>
<div class="alert alert-success text-sm mb-4">
<.icon name="hero-check" class="stroke-current shrink-0 h-4 w-4" />
<span>{@session_success_message}</span>
</div>
<% end %>
<p class="text-sm text-base-content/60 mb-4">
{gettext(
"Devices currently signed in to your account. If you don't recognize one, sign it out."
)}
</p>
<div class="space-y-2">
<%= for session <- @sessions do %>
<div class="flex items-center justify-between gap-3 p-3 rounded-lg border border-base-300">
<div class="flex items-start gap-3 min-w-0">
<.icon
name="hero-computer-desktop"
class="w-5 h-5 mt-0.5 shrink-0 text-base-content/50"
/>
<div class="min-w-0">
<div class="font-medium text-sm">
{device_label(session)}
<span :if={session.is_current} class="badge badge-primary badge-sm ml-1">
{gettext("This device")}
</span>
</div>
<div class="text-xs text-base-content/60 truncate">
{session_meta_line(session)}
</div>
</div>
</div>
<button
:if={not session.is_current}
type="button"
phx-click="revoke_session"
phx-value-uuid={session.token_uuid}
phx-target={@myself}
class="btn btn-ghost btn-xs text-error shrink-0"
>
{gettext("Sign out")}
</button>
</div>
<% end %>
</div>
<div :if={Enum.count(@sessions, &(not &1.is_current)) > 0} class="flex justify-end pt-3">
<button
type="button"
phx-click="revoke_other_sessions"
phx-target={@myself}
data-confirm={gettext("Sign out of all other sessions?")}
class="btn btn-outline btn-error btn-sm"
>
{gettext("Sign out other sessions")}
</button>
</div>
</div>
<% end %>
</div>
</div>
"""
end
end