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: `:profile`, `:email`, `:password`, `:oauth` (default: all four) * `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"`) ## 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.Modules.Storage alias PhoenixKit.Settings alias PhoenixKit.Users.Auth alias PhoenixKit.Users.CustomFields alias PhoenixKit.Users.OAuth alias PhoenixKit.Users.OAuthAvailability alias PhoenixKit.Utils.Routes @default_sections [:profile, :email, :password, :oauth] @impl true def update(%{action: :check_avatar_uploads_complete}, socket) do entries = socket.assigns.uploads.avatar.entries Logger.info( "check_avatar_uploads_complete: entries=#{length(entries)}, done?=#{inspect(Enum.map(entries, & &1.done?))}" ) if entries != [] && Enum.all?(entries, & &1.done?) do Logger.info("Avatar uploads done! Processing...") process_avatar_uploads(socket) else Logger.info("Still uploading avatar, checking again...") send_update_after( __MODULE__, %{id: socket.assigns.id, action: :check_avatar_uploads_complete}, 500 ) {:ok, socket} end end def update(assigns, socket) do user = assigns[:user] || socket.assigns[:user] sections = assigns[:sections] || socket.assigns[:sections] || @default_sections 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 -> setting_options = Settings.get_setting_options() [{"Use System Default", nil} | setting_options["time_zone"]] 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) |> maybe_allow_upload() {:ok, socket} end defp maybe_allow_upload(socket) do if socket.assigns[:uploads] && socket.assigns.uploads[:avatar] do socket else allow_upload(socket, :avatar, accept: ["image/*"], max_entries: 1, max_file_size: 10_000_000, auto_upload: true ) end 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" => user_params} = params 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 = case params["custom_fields"] do custom_fields when is_map(custom_fields) -> Map.put(user_params, "custom_fields", custom_fields) _ -> user_params end 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" => user_params} = params user = socket.assigns.user merged_params = case params["custom_fields"] do custom_fields when is_map(custom_fields) -> existing_avatar = get_in(user.custom_fields, ["avatar_file_uuid"]) updated_custom_fields = if existing_avatar do Map.put(custom_fields, "avatar_file_uuid", existing_avatar) else custom_fields end Map.put(user_params, "custom_fields", updated_custom_fields) _ -> existing_avatar = get_in(user.custom_fields, ["avatar_file_uuid"]) if existing_avatar do Map.put(user_params, "custom_fields", %{"avatar_file_uuid" => existing_avatar}) else user_params end end 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("validate", %{"_target" => ["avatar"]}, socket) do entries = socket.assigns.uploads.avatar.entries Logger.info("avatar validate event: entries=#{length(entries)}") if entries != [] do Logger.info("avatar validate: scheduling check_uploads_complete") send_update_after( __MODULE__, %{id: socket.assigns.id, action: :check_avatar_uploads_complete}, 500 ) end {:noreply, socket} end def handle_event("cancel_upload", %{"ref" => ref}, socket) do {:noreply, cancel_upload(socket, :avatar, ref)} end # Private helpers 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", "apple", "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?("apple"), do: OAuthAvailability.provider_enabled?(:apple) 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 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) defp process_avatar_uploads(socket) do uploaded_avatars = consume_uploaded_entries(socket, :avatar, fn %{path: path}, entry -> ext = Path.extname(entry.client_name) |> String.replace_leading(".", "") current_user = socket.assigns.user user_uuid = current_user.uuid {:ok, stat} = Elixir.File.stat(path) file_size = stat.size file_hash = Auth.calculate_file_hash(path) case Storage.store_file_in_buckets( path, "image", user_uuid, file_hash, ext, entry.client_name ) do {:ok, file, :duplicate} -> Logger.info("Avatar file is duplicate with ID: #{file.uuid}") {:ok, %{ file_uuid: file.uuid, filename: entry.client_name, size: file_size, duplicate: true }} {:ok, file} -> Logger.info("Avatar file stored with ID: #{file.uuid}") {:ok, %{ file_uuid: file.uuid, filename: entry.client_name, size: file_size }} {:error, reason} -> Logger.error("Storage Error: #{inspect(reason)}") {:error, reason} end end) Logger.info("Uploaded avatars: #{inspect(uploaded_avatars)}") avatar_file_uuids = Enum.map(uploaded_avatars, &get_avatar_file_uuid/1) Logger.info("Avatar file UUIDs: #{inspect(avatar_file_uuids)}") avatar_file_uuid = List.first(avatar_file_uuids) Logger.info("First avatar file UUID: #{inspect(avatar_file_uuid)}") socket = if avatar_file_uuid && avatar_file_uuid != nil do user = socket.assigns.user case Auth.update_user_fields(user, %{"avatar_file_uuid" => avatar_file_uuid}) do {:ok, updated_user} -> Logger.info("Avatar file UUID saved: #{avatar_file_uuid}") send(self(), {:phoenix_kit_user_updated, updated_user}) socket |> assign(:user, updated_user) |> assign(:last_uploaded_avatar_uuid, avatar_file_uuid) |> assign(:avatar_success_message, gettext("Avatar uploaded successfully!")) |> assign(:avatar_error_message, nil) {:error, changeset} -> Logger.error("Failed to save avatar file UUID: #{inspect(changeset)}") socket |> assign(:last_uploaded_avatar_uuid, avatar_file_uuid) |> assign( :avatar_error_message, gettext("Avatar uploaded but failed to save to profile") ) |> assign(:avatar_success_message, nil) end else socket |> assign(:avatar_error_message, gettext("Failed to upload avatar")) |> assign(:avatar_success_message, nil) end {:ok, socket} end defp get_avatar_file_uuid(%{file_uuid: file_uuid}), do: file_uuid defp get_avatar_file_uuid({:ok, %{file_uuid: file_uuid}}), do: file_uuid defp get_avatar_file_uuid(_), do: nil @impl Phoenix.LiveComponent def render(assigns) do ~H"""
Upload a profile picture (max 10MB). Accepts JPG, PNG, GIF.
<%!-- Success Message --%> <%= if @last_uploaded_avatar_uuid do %>Change your account email address
<%!-- Email Success Message --%> <%= if @email_success_message do %>Update your account password
<%!-- Password Success Message --%> <%= if @password_success_message do %>Manage OAuth providers for quick sign-in
<%!-- OAuth Success Message --%> <%= if @oauth_success_message do %>