Packages

phoenix_kit

2.28.2
2.62.2 2.62.1 2.62.0 2.61.0 2.60.3 2.60.2 2.60.1 2.60.0 2.59.0 2.58.0 2.57.1 2.57.0 2.56.1 2.56.0 2.55.1 2.55.0 2.54.2 2.54.1 2.54.0 2.53.0 2.52.2 2.52.1 2.52.0 2.51.0 2.50.0 2.49.1 2.49.0 2.48.0 2.47.0 2.46.0 2.45.0 2.44.0 2.43.1 2.43.0 2.42.1 2.42.0 2.41.6 2.41.4 2.41.3 2.41.2 2.41.1 2.41.0 2.40.1 2.40.0 2.39.0 2.38.1 2.38.0 2.37.5 2.37.4 2.37.3 2.37.2 2.37.1 2.37.0 2.36.1 2.36.0 2.35.0 2.34.0 2.33.0 2.32.1 2.32.0 2.31.1 2.31.0 2.30.0 2.29.1 2.29.0 2.28.2 2.28.1 2.28.0 2.27.2 2.27.1 2.27.0 2.26.1 2.26.0 2.25.0 2.24.0 2.23.3 2.23.2 2.23.1 2.23.0 2.22.24 2.22.23 2.22.22 2.22.21 2.22.20 2.22.19 2.22.18 2.22.17 2.22.16 2.22.15 2.22.14 2.22.13 2.22.12 2.22.11 2.22.10 2.22.9 2.22.8 2.22.7 2.22.6 2.22.5 2.22.4 2.22.3 2.22.2 2.22.1 2.22.0 2.21.5 2.21.4 2.21.3 2.21.2 2.21.1 2.21.0 2.20.0 2.19.0 2.18.1 2.18.0 2.17.0 2.16.0 2.15.1 2.15.0 2.14.2 2.14.1 2.14.0 2.13.19 2.13.18 2.13.17 2.13.16 2.13.15 2.13.13 2.13.12 2.13.11 2.13.10 2.13.9 2.13.8 2.13.7 2.13.6 2.13.5 2.13.4 2.13.3 2.13.2 2.13.1 2.13.0 2.12.1 2.12.0 2.11.0 2.10.0 2.9.0 2.8.1 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.0 2.0.1 2.0.0 1.7.236 1.7.235 1.7.234 1.7.233 1.7.232 1.7.231 1.7.230 1.7.229 1.7.228 1.7.227 1.7.226 1.7.225 1.7.224 1.7.223 1.7.222 1.7.221 1.7.220 1.7.219 1.7.218 1.7.217 1.7.216 1.7.215 1.7.214 1.7.213 1.7.212 1.7.211 1.7.210 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
phoenix_kit lib phoenix_kit_web users user_form.ex
Raw

lib/phoenix_kit_web/users/user_form.ex

defmodule PhoenixKitWeb.Users.UserForm do
  @moduledoc """
  LiveView for creating and editing users in the admin interface.

  Provides a form for managing user data including email, password, roles,
  and profile information. Supports both creation of new users and editing
  existing users.
  """
  use PhoenixKitWeb, :live_view

  require Logger

  alias PhoenixKit.Admin.Events
  alias PhoenixKit.Settings
  alias PhoenixKit.Users.Auth
  alias PhoenixKit.Users.CustomFields
  alias PhoenixKit.Users.Invitations
  alias PhoenixKit.Users.Roles
  alias PhoenixKit.Utils.IpAddress
  alias PhoenixKit.Utils.Routes

  def mount(params, _session, socket) do
    user_uuid = params["id"]
    mode = if user_uuid, do: :edit, else: :new

    # Where "Update User"/"Cancel" should navigate back to: the page the
    # admin actually came from (the list, or a specific user's detail page),
    # not always the list. `return_to` is a whitelisted token (not a raw
    # path) so there's no open-redirect surface to worry about.
    return_to = resolve_return_to(params["return_to"], user_uuid)

    # Extract IP and user agent during mount (connect_info is only available here)
    registration_ip = IpAddress.extract_from_socket(socket)

    user_agent =
      case Phoenix.LiveView.get_connect_info(socket, :user_agent) do
        ua when is_binary(ua) -> ua
        _ -> nil
      end

    # Load custom field definitions
    field_definitions = CustomFields.list_enabled_field_definitions()

    # Load all roles for role management
    all_roles = Roles.list_roles()

    # Load default role for new user creation
    default_role = Settings.get_setting("new_user_default_role", "User")

    # Load timezone options — timezone_options/0 directly, not
    # get_setting_options/0: this form already loads all_roles above for
    # its own role-management UI, so building the whole options map here
    # too would query roles a second time for nothing.
    timezone_options = [{"Use System Default", nil} | Settings.timezone_options()]

    # Organization accounts
    org_accounts_enabled =
      Settings.get_setting("enable_organization_accounts", "false") == "true"

    organizations = if org_accounts_enabled, do: Auth.list_organizations(), else: []

    socket =
      socket
      |> assign(:mode, mode)
      |> assign(:user_uuid, user_uuid)
      |> assign(:return_to, return_to)
      |> assign(:page_title, page_title(mode))
      |> assign(:show_reset_password_modal, false)
      |> assign(:show_password_field, false)
      |> assign(:field_definitions, field_definitions)
      |> assign(:custom_fields_errors, %{})
      |> assign(:all_roles, all_roles)
      |> assign(:pending_roles, [])
      |> assign(:default_role, default_role)
      |> assign(:timezone_options, timezone_options)
      |> assign(:show_media_selector, false)
      |> assign(:pending_avatar_file_uuid, nil)
      |> assign(:avatar_changed, false)
      |> assign(:registration_ip, registration_ip)
      |> assign(:org_accounts_enabled, org_accounts_enabled)
      |> assign(:organizations, organizations)
      |> assign(:current_account_type, "person")
      |> assign(:user_agent, user_agent)
      |> assign(:page_section, gettext("Users"))
      |> assign(:page_section_path, Routes.path("/admin/users"))
      |> load_user_data(mode, user_uuid)
      |> assign_credential_authority()
      # Resolved when the selector opens — the host hook may create a folder.
      |> assign(:avatar_scope_folder, nil)
      |> load_form_data()
      |> maybe_set_edit_page_title()

    {:ok, socket}
  end

  defp assign_avatar_scope_folder(socket) do
    actor_uuid =
      socket.assigns[:phoenix_kit_current_user] && socket.assigns.phoenix_kit_current_user.uuid

    assign(
      socket,
      :avatar_scope_folder,
      PhoenixKit.UploadsParentFolder.resolve(:avatar, actor_uuid, socket.assigns[:user])
    )
  end

  # Whether this actor may set a password for, mail a reset link to, or change
  # the email address of the record being edited. Holding the `users` permission
  # gets you onto this page; it does not get you another actor's credentials.
  # Creating a user is unconditional — a record that does not exist yet cannot
  # outrank anyone.
  defp assign_credential_authority(%{assigns: %{mode: :new}} = socket) do
    socket
    |> assign(:can_manage_credentials, true)
    |> assign(:can_manage_status, true)
  end

  defp assign_credential_authority(socket) do
    target = socket.assigns.user
    actor = socket.assigns[:phoenix_kit_current_user]

    socket
    |> assign(:can_manage_credentials, Auth.can_manage_user_credentials?(target, actor))
    |> assign(:can_manage_status, Auth.can_manage_user_status?(target, actor))
  end

  # :new keeps the generic "Create User" title (no record to name yet); :edit
  # names the record being edited so the layout breadcrumb reads
  # "Users / Edit <name>" instead of the context-free "Users / Edit User".
  defp maybe_set_edit_page_title(%{assigns: %{mode: :edit, user: user}} = socket) do
    assign(socket, :page_title, gettext("Edit %{name}", name: user_display_name(user)))
  end

  defp maybe_set_edit_page_title(socket), do: socket

  defp user_display_name(user) do
    cond do
      user.first_name && user.last_name -> "#{user.first_name} #{user.last_name}"
      user.first_name -> user.first_name
      user.username -> user.username
      true -> user.email
    end
  end

  # `return_to` is a whitelisted token from the query string, not a raw path
  # — never interpolate an arbitrary caller-supplied path here.
  defp resolve_return_to("view", user_uuid) when is_binary(user_uuid) do
    Routes.path("/admin/users/view/#{user_uuid}")
  end

  defp resolve_return_to(_return_to, _user_uuid), do: Routes.path("/admin/users")

  def handle_event("open_media_selector", _params, socket) do
    {:noreply,
     socket
     |> assign_avatar_scope_folder()
     |> assign(:show_media_selector, true)}
  end

  def handle_event("validate_user", %{"user" => user_params}, socket) do
    # Filter password from params if password field is not shown
    filtered_params =
      if socket.assigns.mode == :edit and not socket.assigns.show_password_field do
        Map.delete(user_params, "password")
      else
        user_params
      end

    # In edit mode, ensure username is preserved to prevent regeneration from email
    filtered_params =
      if socket.assigns.mode == :edit do
        filtered_params = Map.put_new(filtered_params, "username", socket.assigns.user.username)

        filtered_params
      else
        filtered_params
      end

    changeset =
      case socket.assigns.mode do
        :new ->
          Auth.change_user_registration(%Auth.User{}, filtered_params)

        :edit ->
          Auth.User.profile_changeset(socket.assigns.user, filtered_params, validate_email: false)
      end
      |> Map.put(:action, :validate)

    account_type =
      Map.get(filtered_params, "account_type", socket.assigns.current_account_type)

    socket =
      socket
      |> assign(:changeset, changeset)
      |> assign(:form_data, filtered_params)
      |> assign(:current_account_type, account_type)
      |> assign(
        :custom_fields_data,
        merge_custom_fields_data(socket.assigns.custom_fields_data, user_params["custom_fields"])
      )

    {:noreply, socket}
  end

  def handle_event("save_user", %{"user" => user_params}, socket) do
    case socket.assigns.mode do
      :new -> create_user(socket, user_params)
      :edit -> update_user(socket, user_params)
    end
  end

  def handle_event("cancel", _params, socket) do
    {:noreply, push_navigate(socket, to: socket.assigns.return_to)}
  end

  def handle_event("show_reset_password_modal", _params, socket) do
    if socket.assigns.can_manage_credentials do
      {:noreply, assign(socket, :show_reset_password_modal, true)}
    else
      {:noreply, deny_credential_action(socket)}
    end
  end

  def handle_event("hide_reset_password_modal", _params, socket) do
    socket = assign(socket, :show_reset_password_modal, false)
    {:noreply, socket}
  end

  def handle_event("admin_reset_password", _params, socket) do
    if socket.assigns.can_manage_credentials do
      do_admin_reset_password(socket)
    else
      {:noreply, deny_credential_action(socket)}
    end
  end

  def handle_event("toggle_password_field", _params, socket) do
    if socket.assigns.can_manage_credentials do
      new_show_password_field = !socket.assigns.show_password_field

      socket =
        socket
        |> assign(:show_password_field, new_show_password_field)
        |> reload_changeset_with_password(new_show_password_field)

      {:noreply, socket}
    else
      {:noreply, deny_credential_action(socket)}
    end
  end

  def handle_event("open_roles_dropdown", _params, socket) do
    # Initialize pending roles with current user roles when dropdown opens
    socket = assign(socket, :pending_roles, socket.assigns.user_roles)
    {:noreply, socket}
  end

  def handle_event("toggle_role", %{"role" => role_name}, socket) do
    # Toggle role in pending_roles list
    pending_roles = socket.assigns.pending_roles

    new_pending_roles =
      if role_name in pending_roles do
        List.delete(pending_roles, role_name)
      else
        [role_name | pending_roles]
      end

    socket = assign(socket, :pending_roles, new_pending_roles)
    {:noreply, socket}
  end

  def handle_event("apply_roles", _params, socket) do
    user = socket.assigns.user
    current_user = socket.assigns.phoenix_kit_current_user

    # Prevent users from modifying their own roles
    if user.uuid == current_user.uuid do
      socket =
        put_flash(socket, :error, "You cannot modify your own roles.")

      {:noreply, socket}
    else
      role_names = socket.assigns.pending_roles

      case Roles.sync_user_roles(user, role_names, actor: current_user) do
        {:ok, _assignments} ->
          socket =
            socket
            |> put_flash(:info, "User roles updated successfully.")
            |> assign(:user_roles, Roles.get_user_roles(user))
            |> assign(:pending_roles, [])

          {:noreply, socket}

        {:error, reason} ->
          error_message =
            case reason do
              :owner_role_protected -> "Owner role cannot be manually assigned."
              :cannot_remove_last_owner -> "Cannot remove Owner role from the last Owner."
              _ -> "Failed to update roles. Please try again."
            end

          socket = put_flash(socket, :error, error_message)
          {:noreply, socket}
      end
    end
  end

  def handle_event("sync_user_roles", params, socket) do
    user = socket.assigns.user
    current_user = socket.assigns.phoenix_kit_current_user

    # Prevent users from modifying their own roles
    if user.uuid == current_user.uuid do
      socket =
        put_flash(socket, :error, "You cannot modify your own roles.")

      {:noreply, socket}
    else
      # Extract role names from form params
      role_names =
        params
        |> Map.get("roles", %{})
        |> Enum.filter(fn {_key, value} -> value != "false" end)
        |> Enum.map(fn {key, _value} -> key end)

      case Roles.sync_user_roles(user, role_names, actor: current_user) do
        {:ok, _assignments} ->
          socket =
            socket
            |> put_flash(:info, "User roles updated successfully.")
            |> assign(:user_roles, Roles.get_user_roles(user))

          {:noreply, socket}

        {:error, reason} ->
          error_message =
            case reason do
              :owner_role_protected -> "Owner role cannot be manually assigned."
              :cannot_remove_last_owner -> "Cannot remove Owner role from the last Owner."
              _ -> "Failed to update roles. Please try again."
            end

          socket = put_flash(socket, :error, error_message)
          {:noreply, socket}
      end
    end
  end

  def handle_event("add_member", %{"member_uuid" => member_uuid}, socket) do
    case Auth.set_organization(Auth.get_user!(member_uuid), socket.assigns.user.uuid) do
      {:ok, _} ->
        socket =
          socket
          |> assign(
            :organization_members,
            Auth.list_organization_members(socket.assigns.user.uuid)
          )
          |> assign(
            :available_members,
            Auth.list_available_members_for_organization(socket.assigns.user.uuid)
          )
          |> put_flash(:info, gettext("Member added to organization"))

        {:noreply, socket}

      {:error, reason} when is_binary(reason) ->
        {:noreply, put_flash(socket, :error, reason)}

      {:error, _} ->
        {:noreply, put_flash(socket, :error, gettext("Failed to add member"))}
    end
  end

  def handle_event("remove_member", %{"uuid" => member_uuid}, socket) do
    member = Auth.get_user!(member_uuid)

    case Auth.remove_from_organization(member) do
      {:ok, _} ->
        socket =
          socket
          |> assign(
            :organization_members,
            Auth.list_organization_members(socket.assigns.user.uuid)
          )
          |> assign(
            :available_members,
            Auth.list_available_members_for_organization(socket.assigns.user.uuid)
          )
          |> put_flash(:info, gettext("Member removed from organization"))

        {:noreply, socket}

      {:error, _} ->
        {:noreply, put_flash(socket, :error, gettext("Failed to remove member"))}
    end
  end

  def handle_event("invite_member", %{"email" => email}, socket) do
    organization = socket.assigns.user
    invited_by = socket.assigns.phoenix_kit_current_user
    email = String.trim(email)

    case Invitations.create_invitation(organization, email, invited_by) do
      {:ok, _invitation, _encoded_token} ->
        socket =
          socket
          |> assign(:pending_invitations, Invitations.list_invitations(organization.uuid))
          |> put_flash(:info, gettext("Invitation sent to %{email}", email: email))

        {:noreply, socket}

      {:error, :self_invite} ->
        {:noreply, put_flash(socket, :error, gettext("You cannot invite yourself"))}

      {:error, message} when is_binary(message) ->
        {:noreply, put_flash(socket, :error, message)}

      {:error, _changeset} ->
        {:noreply, put_flash(socket, :error, gettext("Failed to send invitation"))}
    end
  end

  def handle_event("cancel_invitation", %{"uuid" => uuid}, socket) do
    case Invitations.cancel_invitation(uuid) do
      {:ok, _} ->
        organization = socket.assigns.user

        socket =
          socket
          |> assign(:pending_invitations, Invitations.list_invitations(organization.uuid))
          |> put_flash(:info, gettext("Invitation cancelled"))

        {:noreply, socket}

      {:error, _} ->
        {:noreply, put_flash(socket, :error, gettext("Failed to cancel invitation"))}
    end
  end

  def handle_event("toggle_user_status", _params, socket) do
    if socket.assigns.can_manage_status do
      do_toggle_user_status(socket)
    else
      {:noreply,
       put_flash(
         socket,
         :error,
         gettext("You don't have permission to change this user's status")
       )}
    end
  end

  defp do_toggle_user_status(socket) do
    user = socket.assigns.user
    new_status = !user.is_active

    case Auth.update_user_status(user, %{"is_active" => new_status},
           actor: socket.assigns[:phoenix_kit_current_user]
         ) do
      {:ok, updated_user} ->
        status_text = if new_status, do: "activated", else: "deactivated"

        socket =
          socket
          |> put_flash(:info, "User #{status_text} successfully.")
          |> assign(:user, updated_user)
          |> reload_changeset_after_status_update(updated_user)

        {:noreply, socket}

      {:error, :cannot_deactivate_last_owner} ->
        socket =
          put_flash(
            socket,
            :error,
            "Cannot deactivate the last Owner. Assign the Owner role to another user first."
          )

        {:noreply, socket}

      {:error, _changeset} ->
        socket =
          put_flash(socket, :error, "Failed to update user status. Please try again.")

        {:noreply, socket}
    end
  end

  defp do_admin_reset_password(socket) do
    user = socket.assigns.user

    case Auth.deliver_user_reset_password_instructions(
           user,
           &Routes.url("/users/reset-password/#{&1}")
         ) do
      {:ok, _} ->
        socket =
          socket
          |> put_flash(
            :info,
            "Password reset email sent to #{user.email}. The user will receive instructions to reset their password."
          )
          |> assign(:show_reset_password_modal, false)

        {:noreply, socket}

      {:error, _reason} ->
        socket =
          put_flash(socket, :error, "Failed to send password reset email. Please try again.")

        {:noreply, socket}
    end
  end

  # One refusal for every credential action. The template hides these controls,
  # but a LiveView event is a websocket message the client composes itself, so
  # hiding is presentation and this is the control.
  defp deny_credential_action(socket) do
    put_flash(
      socket,
      :error,
      gettext("You don't have permission to manage this user's credentials")
    )
  end

  # Everything after the insert is best effort. The row is committed the moment
  # `admin_create_user/2` returns, so a raise from the confirmation mailer (the
  # one unguarded step) killed this LiveView AFTER the user existed: the page
  # remounted, form recovery refilled every field, and a second "Create User"
  # was told the email was taken. Nothing past the insert may take the navigation away.
  defp create_user(socket, user_params) do
    admin_user = socket.assigns[:phoenix_kit_current_user]

    case Auth.admin_create_user(user_params, admin_user) do
      {:ok, user} ->
        email_result = send_confirmation_email(user)
        log_user_created(user, admin_user)

        socket =
          socket
          |> put_flash(email_result_flash_kind(email_result), created_flash(email_result))
          |> push_navigate(to: Routes.path("/admin/users/view/#{user.uuid}"))

        {:noreply, socket}

      {:error, %Ecto.Changeset{} = changeset} ->
        socket =
          socket
          |> assign(:changeset, changeset)
          |> assign(:form_data, user_params)

        {:noreply, socket}
    end
  end

  defp send_confirmation_email(user) do
    case Auth.deliver_user_confirmation_instructions(
           user,
           &Routes.url("/users/confirm/#{&1}")
         ) do
      {:ok, _} ->
        :sent

      {:error, reason} ->
        Logger.warning(
          "PhoenixKit: admin-created user #{user.uuid} got no confirmation email: #{inspect(reason)}"
        )

        :failed
    end
  rescue
    error ->
      Logger.error(
        "PhoenixKit: confirmation email for admin-created user #{user.uuid} raised: " <>
          Exception.format(:error, error, __STACKTRACE__)
      )

      :failed
  catch
    :exit, reason ->
      Logger.error(
        "PhoenixKit: confirmation email for admin-created user #{user.uuid} exited: #{inspect(reason)}"
      )

      :failed
  end

  # `Activity.log/1` rescues and catches exits itself.
  defp log_user_created(user, admin_user) do
    PhoenixKit.Activity.log(%{
      action: "user.created",
      module: "users",
      mode: "manual",
      actor_uuid: admin_user.uuid,
      resource_type: "user",
      resource_uuid: user.uuid,
      target_uuid: user.uuid,
      metadata: %{"method" => "manual", "actor_role" => "admin"}
    })
  end

  defp email_result_flash_kind(:sent), do: :info
  defp email_result_flash_kind(:failed), do: :warning

  defp created_flash(:sent), do: gettext("User created. A confirmation email was sent.")

  defp created_flash(:failed),
    do: gettext("User created, but the confirmation email could not be sent.")

  defp update_user(socket, user_params) do
    user = socket.assigns.user

    # Dropped unconditionally, not only when the actor is out of rank. The form
    # renders a real input for every one of these, so a `custom_fields` entry
    # carrying one is never a legitimate submission from this page — and an
    # unconditional filter cannot go stale the way an authority assign computed
    # at mount can.
    #
    # Without this, the credential rank rule was bypassable end to end: the
    # `Map.drop` in `update_user_profile/3` guards `profile_params`, and
    # `custom_fields` went straight to `Auth.update_user_fields/2`, which wrote
    # `:email` into the schema with no confirmation-token flow. An actor holding
    # only the `users` permission could point an Owner's address at their own
    # inbox and then take the account through the public password-reset page.
    custom_fields_params =
      user_params
      |> Map.get("custom_fields", %{})
      |> drop_schema_identity_fields(socket)

    # Include pending avatar if it was changed via media selector
    custom_fields_params =
      if socket.assigns[:avatar_changed] && socket.assigns[:pending_avatar_file_uuid] do
        Map.put(custom_fields_params, "avatar_file_uuid", socket.assigns.pending_avatar_file_uuid)
      else
        custom_fields_params
      end

    profile_params = Map.delete(user_params, "custom_fields")

    avatar_changed? = socket.assigns[:avatar_changed] == true
    avatar_file_uuid = socket.assigns[:pending_avatar_file_uuid]

    with :ok <- validate_custom_fields(user, custom_fields_params),
         {:ok, updated_user} <- update_user_profile(socket, user, profile_params),
         {:ok, user_with_fields} <- update_custom_fields(updated_user, custom_fields_params),
         {:ok, user_with_account_type} <-
           update_account_type_fields(user_with_fields, user_params),
         result <- update_user_roles_if_changed(socket, user_with_account_type) do
      # Log avatar change if it happened
      if avatar_changed? && avatar_file_uuid do
        admin = socket.assigns[:phoenix_kit_current_user]

        PhoenixKit.Activity.log(%{
          action: "user.avatar_changed",
          module: "users",
          mode: "manual",
          actor_uuid: admin && admin.uuid,
          resource_type: "user",
          resource_uuid: user_with_account_type.uuid,
          target_uuid: user_with_account_type.uuid,
          metadata: %{
            "avatar_from" => get_in(user.custom_fields, ["avatar_file_uuid"]) || "",
            "avatar_to" => avatar_file_uuid,
            "actor_role" => "admin"
          }
        })
      end

      # Clear avatar change flag after successful update
      socket =
        socket
        |> assign(:avatar_changed, false)
        |> assign(:pending_avatar_file_uuid, nil)

      handle_update_result(socket, result)
    else
      {:error, %Ecto.Changeset{} = changeset} ->
        handle_profile_update_error(socket, changeset, user_params)

      {:error, :custom_fields, errors} ->
        handle_custom_fields_error(socket, errors, custom_fields_params)

      {:error, :custom_fields_save} ->
        handle_custom_fields_save_error(socket)

      # Without this clause the `with` raises `WithClauseError`: the atom matches
      # neither the changeset clause above nor the binary one below.
      {:error, :insufficient_permissions} ->
        {:noreply, deny_credential_action(socket)}

      {:error, reason} when is_binary(reason) ->
        {:noreply, put_flash(socket, :error, reason)}
    end
  end

  defp validate_custom_fields(_user, custom_fields_params)
       when map_size(custom_fields_params) == 0 do
    :ok
  end

  defp validate_custom_fields(user, custom_fields_params) do
    # Validate what will actually be STORED, not just what was submitted:
    # `update_custom_fields/2` merges through `Auth.update_user_fields/2`, so a
    # key absent from the submission keeps its stored value. Validating the
    # submission alone made every enabled definition that the form does not
    # render an input for look empty — which, for a `required` one, blocked
    # every save on the page with "Field is required" for a field nobody could
    # fill in. The form stopped submitting exactly such a key when its value is
    # a map or a list (issue #780).
    merged = Map.merge(user.custom_fields || %{}, custom_fields_params)
    temp_user = %{user | custom_fields: merged}

    case CustomFields.validate_user_custom_fields(temp_user) do
      :ok -> :ok
      {:error, errors} -> {:error, :custom_fields, errors}
    end
  end

  defp update_user_profile(socket, user, profile_params) do
    # Defence in depth at the write path. The events above already refuse, but
    # `email` is an ordinary field of this form and owning the address a reset
    # link is delivered to takes an account just as surely as setting the
    # password does — so an actor who may not manage this record's credentials
    # gets both fields dropped here, whatever the params say.
    #
    # Asked again here rather than read off `@can_manage_credentials`, which is
    # computed once in `mount/3`. The context re-evaluates the same rule when the
    # password is written, so a stale assign made the two disagree: the form
    # would write the credential fields on the strength of the old answer and
    # then take a refusal from the context, leaving a partial write behind — and
    # the refusal arrived as `{:error, :insufficient_permissions}`, which the
    # clause below expects to be a changeset. The assign stays as the UI's
    # answer; the write path asks for a fresh one.
    profile_params =
      if credential_authority_now(socket, user) do
        profile_params
      else
        # `username` goes with them: it is the second thing
        # `get_user_by_email_or_username_and_password/3` accepts, so rewriting it
        # takes away a sign-in route from an account the actor may not manage.
        Map.drop(profile_params, ["password", "email", "username"])
      end

    password_provided =
      socket.assigns.show_password_field &&
        Map.has_key?(profile_params, "password") &&
        profile_params["password"] != nil &&
        String.trim(profile_params["password"]) != ""

    result =
      if password_provided do
        update_profile_and_password(socket, user, profile_params)
      else
        cleaned_params = Map.delete(profile_params, "password")
        update_user_profile_without_validation(user, cleaned_params)
      end

    case result do
      {:ok, updated_user} ->
        admin = socket.assigns[:phoenix_kit_current_user]

        # Build a changeset from old→new to detect what changed
        # Dynamically compare all profile fields instead of hardcoding
        profile_fields = ~w(email username first_name last_name user_timezone)a

        new_values =
          profile_fields
          |> Enum.map(fn field -> {field, Map.get(updated_user, field)} end)
          |> Map.new()

        changeset = Ecto.Changeset.change(user, new_values)

        PhoenixKit.Activity.log_user_change("user.profile_updated", user, changeset,
          actor_uuid: admin && admin.uuid,
          target_uuid: user.uuid,
          mode: "manual",
          actor_role: "admin"
        )

        {:ok, updated_user}

      error ->
        error
    end
  end

  defp update_user_profile_without_validation(user, attrs) do
    changeset = Auth.User.profile_changeset(user, attrs, validate_email: false)

    case changeset |> PhoenixKit.RepoHelper.repo().update() do
      {:ok, updated_user} ->
        Events.broadcast_user_updated(updated_user)

        {:ok, updated_user}

      {:error, changeset} ->
        {:error, changeset}
    end
  end

  defp update_custom_fields(user, custom_fields_params)
       when map_size(custom_fields_params) == 0 do
    {:ok, user}
  end

  defp update_custom_fields(user, custom_fields_params) do
    # Use update_user_fields instead of update_user_custom_fields to preserve existing fields (like avatar_file_uuid)
    case Auth.update_user_fields(user, custom_fields_params) do
      {:ok, updated_user} -> {:ok, updated_user}
      {:error, _changeset} -> {:error, :custom_fields_save}
    end
  end

  # Names that `Auth.update_user_fields/2` routes OUT of `custom_fields` and
  # into the schema: it resolves each key with `String.to_existing_atom/1` and
  # writes the value through `profile_changeset` when the name matches one of
  # these. Read from the context rather than restated here — a copy would go
  # stale the next time a profile field is added there, and silently, since the
  # only symptom is that one field becoming writable through `custom_fields`
  # again.
  defp schema_identity_fields do
    Enum.map(Auth.updatable_profile_fields(), &Atom.to_string/1)
  end

  # Params arrive string-keyed from the wire; a non-map (a client sending
  # `custom_fields=1`) is passed through untouched so the existing validation
  # rejects it, rather than crashing here.
  defp drop_schema_identity_fields(params, socket) when is_map(params) do
    fields = schema_identity_fields()

    case Map.take(params, fields) do
      empty when map_size(empty) == 0 ->
        params

      dropped ->
        # Logged for the same reason the context logs its refusal: this page
        # renders a real input for every one of these names, so a `custom_fields`
        # entry carrying one cannot be produced by the form and is a client
        # composing its own payload. Dropping it silently is correct; dropping it
        # invisibly leaves the one signal an operator would want.
        actor = socket.assigns[:phoenix_kit_current_user]

        Logger.warning(
          "PhoenixKit: dropped schema identity fields #{inspect(Map.keys(dropped))} " <>
            "from custom_fields for #{socket.assigns.user.uuid} " <>
            "submitted by #{(actor && actor.uuid) || "an unauthenticated caller"}"
        )

        Map.drop(params, fields)
    end
  end

  defp drop_schema_identity_fields(params, _socket), do: params

  defp update_account_type_fields(user, user_params) do
    account_type = Map.get(user_params, "account_type")

    if account_type do
      org_uuid = Map.get(user_params, "organization_uuid")
      org_uuid = if org_uuid == "" or is_nil(org_uuid), do: nil, else: org_uuid

      attrs = %{
        "account_type" => account_type,
        "organization_name" => Map.get(user_params, "organization_name"),
        "organization_uuid" => org_uuid
      }

      Auth.change_account_type(user, attrs)
    else
      {:ok, user}
    end
  end

  defp handle_update_result(socket, {:ok, _result}) do
    # _result could be either the updated user or role assignments (from sync_user_roles)
    # In both cases, we need to reload the user from the database to get the fresh data
    user_uuid = socket.assigns.user.uuid
    fresh_user = Auth.get_user!(user_uuid)

    socket =
      socket
      |> assign(:user, fresh_user)
      |> reload_changeset_with_updated_user(fresh_user)
      |> put_flash(:info, "User updated successfully.")
      |> push_navigate(to: socket.assigns.return_to)

    {:noreply, socket}
  end

  defp handle_update_result(socket, {:error, reason}) do
    error_message = format_role_update_error(reason)

    socket =
      socket
      |> put_flash(:warning, error_message)
      |> push_navigate(to: socket.assigns.return_to)

    {:noreply, socket}
  end

  defp reload_changeset_with_updated_user(socket, updated_user) do
    form_data = socket.assigns.form_data || %{}
    # Ensure username is in form_data to prevent regeneration from email
    form_data = Map.put_new(form_data, "username", updated_user.username)
    # Use profile_changeset for edit mode to avoid username regeneration
    changeset = Auth.User.profile_changeset(updated_user, form_data, validate_email: false)
    assign(socket, :changeset, changeset)
  end

  defp format_role_update_error(:owner_role_protected) do
    "User profile updated but Owner role cannot be manually assigned."
  end

  defp format_role_update_error(:cannot_remove_last_owner) do
    "User profile updated but cannot remove Owner role from the last Owner."
  end

  defp format_role_update_error(_) do
    "User profile updated but roles failed to update."
  end

  defp handle_profile_update_error(socket, changeset, user_params) do
    socket =
      socket
      |> assign(:changeset, changeset)
      |> assign(:form_data, user_params)
      |> assign(:custom_fields_errors, %{})

    {:noreply, socket}
  end

  defp handle_custom_fields_error(socket, errors, custom_fields_params) do
    socket =
      socket
      |> assign(:custom_fields_errors, errors)
      |> assign(
        :custom_fields_data,
        merge_custom_fields_data(socket.assigns.custom_fields_data, custom_fields_params)
      )
      |> put_flash(:error, "Please fix the custom field errors below.")

    {:noreply, socket}
  end

  # Submitted params are MERGED over what is already displayed, never swapped
  # in wholesale. A field definition whose stored value is a map or a list
  # renders read-only and without a `name` (see the template), so it is absent
  # from every submission — replacing the assign would blank it out of the page
  # on the first keystroke. Merging also mirrors what the save path does to the
  # column itself: `Auth.update_user_fields/2` merges, so an unsubmitted key
  # keeps its stored value (issue #780).
  defp merge_custom_fields_data(existing, params) when is_map(params) do
    Map.merge(existing || %{}, params)
  end

  defp merge_custom_fields_data(existing, _params), do: existing || %{}

  defp handle_custom_fields_save_error(socket) do
    socket =
      socket
      |> put_flash(:error, "User profile updated but custom fields failed to save.")
      |> push_navigate(to: socket.assigns.return_to)

    {:noreply, socket}
  end

  defp load_user_data(socket, :new, _user_uuid) do
    socket
    |> assign(:user, %Auth.User{})
    |> assign(:user_roles, [])
    |> assign(:organization_members, [])
    |> assign(:available_members, [])
    |> assign(:pending_invitations, [])
  end

  defp load_user_data(socket, :edit, user_uuid) do
    user = Auth.get_user!(user_uuid)
    user_roles = Roles.get_user_roles(user)

    # Load organization members if this is an organization account
    organization_members =
      if socket.assigns.org_accounts_enabled && user.account_type == "organization" do
        Auth.list_organization_members(user.uuid)
      else
        []
      end

    # Load all users for add-member dropdown (persons without an org)
    available_members =
      if socket.assigns.org_accounts_enabled && user.account_type == "organization" do
        Auth.list_available_members_for_organization(user.uuid)
      else
        []
      end

    # Load pending invitations for organization accounts
    pending_invitations =
      if socket.assigns.org_accounts_enabled && user.account_type == "organization" do
        Invitations.list_invitations(user.uuid)
      else
        []
      end

    socket
    |> assign(:user, user)
    |> assign(:user_roles, user_roles)
    |> assign(:pending_roles, user_roles)
    |> assign(:organization_members, organization_members)
    |> assign(:available_members, available_members)
    |> assign(:pending_invitations, pending_invitations)
  end

  defp load_form_data(%{assigns: %{mode: :new}} = socket) do
    changeset = Auth.change_user_registration(%Auth.User{}, %{})

    socket
    |> assign(:changeset, changeset)
    |> assign(:form_data, %{
      "email" => "",
      "password" => "",
      "first_name" => "",
      "last_name" => ""
    })
    |> assign(:custom_fields_data, %{})
  end

  defp load_form_data(%{assigns: %{mode: :edit, user: user}} = socket) do
    # For edit mode, use profile_changeset instead of registration_changeset
    # profile_changeset doesn't call maybe_generate_username_from_email like registration_changeset does
    changeset =
      Auth.User.profile_changeset(user, %{"username" => user.username}, validate_email: false)

    account_type = user.account_type || "person"

    socket
    |> assign(:changeset, changeset)
    |> assign(:current_account_type, account_type)
    |> assign(:form_data, %{
      "email" => user.email || "",
      "username" => user.username || "",
      "first_name" => user.first_name || "",
      "last_name" => user.last_name || "",
      "user_timezone" => user.user_timezone || "0",
      "account_type" => account_type,
      "organization_name" => user.organization_name || "",
      "organization_uuid" => (user.organization_uuid && to_string(user.organization_uuid)) || ""
    })
    |> assign(:custom_fields_data, user.custom_fields || %{})
  end

  defp page_title(:new), do: gettext("Create User")
  defp page_title(:edit), do: gettext("Edit User")

  defp reload_changeset_with_password(socket, show_password_field) do
    case socket.assigns.mode do
      :new ->
        # For new users, password is always required
        socket

      :edit ->
        # For edit mode, reload changeset to include/exclude password field
        user = socket.assigns.user
        form_data = socket.assigns.form_data || %{}

        # Create changeset with or without password validation
        changeset =
          if show_password_field do
            # Include password in changeset when field is shown
            Auth.change_user_registration(user, form_data)
          else
            # Standard profile changeset when password field is hidden
            Auth.change_user_registration(user, Map.delete(form_data, "password"))
          end

        assign(socket, :changeset, changeset)
    end
  end

  defp reload_changeset_after_status_update(socket, updated_user) do
    form_data = socket.assigns.form_data || %{}
    changeset = Auth.change_user_registration(updated_user, form_data)
    assign(socket, :changeset, changeset)
  end

  # The rule itself stays in the context — this only re-asks it at write time
  # instead of reading `@can_manage_credentials`, which is computed once in
  # `mount/3` and can be minutes old by the time the form is submitted.
  #
  # Note what is and is not load-bearing here. `Auth.get_user!/1` does NOT
  # preload `:roles`, so the mounted struct's association is `NotLoaded`,
  # `Auth.has_system_role?/2` takes its querying clause, and
  # `Roles.user_has_role?/2` keys on `user.uuid` alone — the rank half of the
  # answer is therefore already fresh whichever struct is passed, and the same
  # holds for the actor. What the reload buys is the rest: a target deleted
  # since mount answers `false` here instead of being written, and the check
  # keeps its meaning if `load_user_data/3` ever starts preloading `:roles`
  # (at which point the mounted struct WOULD freeze the answer at mount).
  defp credential_authority_now(socket, user) do
    case Auth.get_user(user.uuid) do
      %Auth.User{} = fresh ->
        Auth.can_manage_user_credentials?(fresh, socket.assigns[:phoenix_kit_current_user])

      _ ->
        false
    end
  end

  defp update_profile_and_password(socket, user, user_params) do
    # First validate profile update
    profile_params = Map.delete(user_params, "password")

    case Auth.update_user_profile(user, profile_params) do
      {:ok, updated_user} ->
        # If profile update succeeded, update password
        password_params = Map.take(user_params, ["password"])

        # Build audit context from socket
        context = build_audit_context(socket)

        case Auth.admin_update_user_password(updated_user, password_params, context) do
          {:ok, final_user} ->
            {:ok, final_user}

          # The context refuses with an atom, not a changeset. Passed straight
          # through, because `merge_password_errors/2` below would call `.errors`
          # on it and take the LiveView down with a `BadMapError`. Reaching this
          # clause means the fresh authority check above and the context's own
          # disagreed, which should not happen — but a crash is a poor way to
          # find that out.
          {:error, :insufficient_permissions} ->
            {:error, :insufficient_permissions}

          {:error, password_changeset} ->
            # If password update failed, return a combined changeset
            profile_changeset = Auth.change_user_registration(user, user_params)
            combined_changeset = merge_password_errors(profile_changeset, password_changeset)
            {:error, combined_changeset}
        end

      {:error, profile_changeset} ->
        # Profile update failed, return the profile changeset with password field
        {:error, profile_changeset}
    end
  end

  defp log_roles_updated(admin, user, current_roles, new_roles, added, removed) do
    metadata =
      %{"actor_role" => "admin"}
      |> maybe_put_role_diff("added", added)
      |> maybe_put_role_diff("removed", removed)
      |> Map.put("roles_from", Enum.join(Enum.sort(current_roles), ", "))
      |> Map.put("roles_to", Enum.join(Enum.sort(new_roles), ", "))

    PhoenixKit.Activity.log(%{
      action: "user.roles_updated",
      module: "users",
      mode: "manual",
      actor_uuid: admin.uuid,
      resource_type: "user",
      resource_uuid: user.uuid,
      target_uuid: user.uuid,
      metadata: metadata
    })
  end

  defp maybe_put_role_diff(map, _key, []), do: map
  defp maybe_put_role_diff(map, key, roles), do: Map.put(map, key, Enum.join(roles, ", "))

  defp build_audit_context(socket) do
    %{
      admin_user: socket.assigns[:phoenix_kit_current_user],
      ip_address: socket.assigns[:registration_ip],
      user_agent: socket.assigns[:user_agent]
    }
  end

  defp merge_password_errors(profile_changeset, password_changeset) do
    # Merge password errors into the profile changeset
    password_errors = password_changeset.errors

    Enum.reduce(password_errors, profile_changeset, fn {field, error}, acc ->
      Ecto.Changeset.add_error(acc, field, elem(error, 0), elem(error, 1))
    end)
  end

  defp update_user_roles_if_changed(socket, user) do
    current_user = socket.assigns.phoenix_kit_current_user
    pending_roles = socket.assigns.pending_roles
    current_roles = socket.assigns.user_roles

    # Check if roles have changed
    roles_changed? = Enum.sort(pending_roles) != Enum.sort(current_roles)

    cond do
      # If user is trying to modify their own roles, prevent it
      user.uuid == current_user.uuid and roles_changed? ->
        {:error, :cannot_modify_own_roles}

      # If roles haven't changed, skip update
      not roles_changed? ->
        {:ok, user}

      # Roles have changed and it's allowed, update them
      true ->
        case Roles.sync_user_roles(user, pending_roles, actor: current_user) do
          {:ok, %{roles_before: roles_before, roles_after: roles_after}} = result ->
            # Audit the exact delta the transaction applied (before/after captured
            # inside it), not the submitted set — the context silently drops
            # changes the actor isn't authorized to make. Skip the log when
            # nothing actually changed (e.g. a fully-rejected submission),
            # mirroring the LiveView role modal.
            added = roles_after -- roles_before
            removed = roles_before -- roles_after

            if added != [] or removed != [] do
              log_roles_updated(current_user, user, roles_before, roles_after, added, removed)
            end

            result

          error ->
            error
        end
    end
  end

  def handle_info({:media_selected, file_uuids}, socket) do
    # Get the first selected file UUID (single selection mode)
    avatar_file_uuid = List.first(file_uuids)

    # Store avatar selection without saving to database yet
    # Avatar will be saved when "Update User" button is clicked
    socket =
      if avatar_file_uuid do
        Logger.info("Avatar selected (pending save): #{avatar_file_uuid}")

        socket
        |> assign(:pending_avatar_file_uuid, avatar_file_uuid)
        |> assign(:avatar_changed, true)
        |> assign(:show_media_selector, false)
        |> put_flash(:info, "Avatar selected. Click 'Update User' to save.")
      else
        socket
        |> assign(:show_media_selector, false)
      end

    {:noreply, socket}
  end

  def handle_info({:media_selector_closed}, socket) do
    {:noreply, assign(socket, :show_media_selector, false)}
  end
end