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 -> 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) |> 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"""
{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." )}
{gettext( "Devices currently signed in to your account. If you don't recognize one, sign it out." )}