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` (default: all five). `: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"`) ## 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.Settings alias PhoenixKit.Users.Auth alias PhoenixKit.Users.CustomFields alias PhoenixKit.Users.OAuth alias PhoenixKit.Users.OAuthAvailability alias PhoenixKit.Utils.Routes @default_sections [:identity, :custom_fields, :email, :password, :oauth] @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}) {: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 -> 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) |> assign_new(:show_avatar_selector, fn -> false end) |> assign_new(:show_email_form, fn -> false end) |> assign_new(:show_password_form, fn -> false 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 # 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 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"""