defmodule MvWeb.UserLive.Form do @moduledoc """ LiveView form for creating and editing users. ## Features - Create new users with email - Edit existing user details - Optional password setting (checkbox to toggle) - Link/unlink member accounts - Email synchronization with linked members ## Form Fields **Required:** - email **Optional:** - password (for password authentication strategy) - linked member (select from existing members) ## Password Management - New users: Can optionally set password with confirmation - Existing users: Can change password (no confirmation required, admin action) - Checkbox toggles password section visibility ## Member Linking Users can be linked to existing member accounts. When linked, emails are synchronized bidirectionally with User.email as the source of truth. ## Events - `validate` - Real-time form validation - `save` - Submit form (create or update user) - `toggle_password_section` - Show/hide password fields """ use MvWeb, :live_view @impl true def render(assigns) do ~H""" <.header> {@page_title} <:subtitle>{gettext("Use this form to manage user records in your database.")} <.form for={@form} id="user-form" phx-change="validate" phx-submit="save"> <.input field={@form[:email]} label={gettext("Email")} required type="email" />
<%= if @show_password_fields do %>
<.input field={@form[:password]} label={gettext("Password")} type="password" required autocomplete="new-password" /> <%= if !@user do %> <.input field={@form[:password_confirmation]} label={gettext("Confirm Password")} type="password" required autocomplete="new-password" /> <% end %>

{gettext("Password requirements")}:

  • {gettext("At least 8 characters")}
  • {gettext("Include both letters and numbers")}
  • {gettext("Consider using special characters")}
<%= if @user do %>

{gettext("Admin Note")}: {gettext( "As an administrator, you can directly set a new password for this user using the same secure Ash Authentication system." )}

<% end %>
<% else %> <%= if @user do %>

{gettext("Note")}: {gettext( "Check 'Change Password' above to set a new password for this user." )}

<% else %>

{gettext("Note")}: {gettext( "User will be created without a password. Check 'Set Password' to add one." )}

<% end %> <% end %>

{gettext("Linked Member")}

<%= if @user && @user.member && !@unlink_member do %>

{@user.member.first_name} {@user.member.last_name}

{@user.member.email}

<% else %> <%= if @unlink_member do %>

{gettext("Unlinking scheduled")}: {gettext( "Member will be unlinked when you save. Cannot select new member until saved." )}

<% end %>
<%= if length(@available_members) > 0 do %>
<%= for {member, index} <- Enum.with_index(@available_members) do %>

{member.first_name} {member.last_name}

{member.email}

<% end %>
<% end %>
<%= if @user && @user.email && @available_members != [] && Enum.all?(@available_members, &(&1.email == to_string(@user.email))) do %>

{gettext("Note")}: {gettext( "A member with this email already exists. To link with a different member, please change one of the email addresses first." )}

<% end %> <%= if @selected_member_id && @selected_member_name do %>

{gettext("Selected")}: {@selected_member_name}

{gettext("Save to confirm linking.")}

<% end %>
<% end %>
<.button phx-disable-with={gettext("Saving...")} variant="primary"> {gettext("Save User")} <.button navigate={return_path(@return_to, @user)}>{gettext("Cancel")}
""" end @impl true def mount(params, _session, socket) do user = case params["id"] do nil -> nil id -> Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member]) end action = if is_nil(user), do: gettext("New"), else: gettext("Edit") page_title = action <> " " <> gettext("User") {:ok, socket |> assign(:return_to, return_to(params["return_to"])) |> assign(user: user) |> assign(:page_title, page_title) |> assign(:show_password_fields, false) |> assign(:member_search_query, "") |> assign(:available_members, []) |> assign(:show_member_dropdown, false) |> assign(:selected_member_id, nil) |> assign(:selected_member_name, nil) |> assign(:unlink_member, false) |> assign(:focused_member_index, nil) |> load_initial_members() |> assign_form()} end @spec return_to(String.t() | nil) :: String.t() defp return_to("show"), do: "show" defp return_to(_), do: "index" @impl true def handle_event("toggle_password_section", _params, socket) do show_password_fields = !socket.assigns.show_password_fields socket = socket |> assign(:show_password_fields, show_password_fields) |> assign_form() {:noreply, socket} end def handle_event("validate", %{"user" => user_params}, socket) do validated_form = AshPhoenix.Form.validate(socket.assigns.form, user_params) # Reload members if email changed (for email-match priority) socket = if Map.has_key?(user_params, "email") do user_email = user_params["email"] members = load_members_for_linking(user_email, socket.assigns.member_search_query) assign(socket, form: validated_form, available_members: members) else assign(socket, form: validated_form) end {:noreply, socket} end def handle_event("save", %{"user" => user_params}, socket) do # First save the user without member changes case AshPhoenix.Form.submit(socket.assigns.form, params: user_params) do {:ok, user} -> # Then handle member linking/unlinking as a separate step result = cond do # Selected member ID takes precedence (new link) socket.assigns.selected_member_id -> Mv.Accounts.update_user(user, %{member: %{id: socket.assigns.selected_member_id}}) # Unlink flag is set socket.assigns[:unlink_member] -> Mv.Accounts.update_user(user, %{member: nil}) # No changes to member relationship true -> {:ok, user} end case result do {:ok, updated_user} -> notify_parent({:saved, updated_user}) socket = socket |> put_flash(:info, "User #{socket.assigns.form.source.type}d successfully") |> push_navigate(to: return_path(socket.assigns.return_to, updated_user)) {:noreply, socket} {:error, error} -> # Show user-friendly error from member linking/unlinking error_message = extract_error_message(error) {:noreply, put_flash( socket, :error, gettext("Failed to link member: %{error}", error: error_message) )} end {:error, form} -> {:noreply, assign(socket, form: form)} end end def handle_event("show_member_dropdown", _params, socket) do {:noreply, assign(socket, show_member_dropdown: true)} end def handle_event("hide_member_dropdown", _params, socket) do {:noreply, assign(socket, show_member_dropdown: false, focused_member_index: nil)} end def handle_event("member_dropdown_keydown", %{"key" => "ArrowDown"}, socket) do return_if_dropdown_closed(socket, fn -> max_index = length(socket.assigns.available_members) - 1 current = socket.assigns.focused_member_index new_index = case current do nil -> 0 index when index < max_index -> index + 1 _ -> current end {:noreply, assign(socket, focused_member_index: new_index)} end) end def handle_event("member_dropdown_keydown", %{"key" => "ArrowUp"}, socket) do return_if_dropdown_closed(socket, fn -> current = socket.assigns.focused_member_index new_index = case current do nil -> 0 0 -> 0 index -> index - 1 end {:noreply, assign(socket, focused_member_index: new_index)} end) end def handle_event("member_dropdown_keydown", %{"key" => "Enter"}, socket) do return_if_dropdown_closed(socket, fn -> select_focused_member(socket) end) end def handle_event("member_dropdown_keydown", %{"key" => "Escape"}, socket) do return_if_dropdown_closed(socket, fn -> {:noreply, assign(socket, show_member_dropdown: false, focused_member_index: nil)} end) end def handle_event("member_dropdown_keydown", _params, socket) do # Ignore other keys {:noreply, socket} end def handle_event("search_members", %{"member_search" => query}, socket) do socket = socket |> assign(:member_search_query, query) |> load_available_members(query) |> assign(:show_member_dropdown, true) |> assign(:focused_member_index, nil) {:noreply, socket} end def handle_event("select_member", %{"id" => member_id}, socket) do # Find the selected member to get their name selected_member = Enum.find(socket.assigns.available_members, &(&1.id == member_id)) member_name = if selected_member, do: "#{selected_member.first_name} #{selected_member.last_name}", else: "" # Store the selected member ID and name in socket state and clear unlink flag socket = socket |> assign(:selected_member_id, member_id) |> assign(:selected_member_name, member_name) |> assign(:unlink_member, false) |> assign(:show_member_dropdown, false) |> assign(:member_search_query, member_name) |> push_event("set-input-value", %{id: "member-search-input", value: member_name}) {:noreply, socket} end def handle_event("unlink_member", _params, socket) do # Set flag to unlink member on save # Clear all member selection state and keep dropdown hidden socket = socket |> assign(:unlink_member, true) |> assign(:selected_member_id, nil) |> assign(:selected_member_name, nil) |> assign(:member_search_query, "") |> assign(:show_member_dropdown, false) |> load_initial_members() {:noreply, socket} end @spec notify_parent(any()) :: any() defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) # Helper to ignore keyboard events when dropdown is closed @spec return_if_dropdown_closed(Phoenix.LiveView.Socket.t(), function()) :: {:noreply, Phoenix.LiveView.Socket.t()} defp return_if_dropdown_closed(socket, func) do if socket.assigns.show_member_dropdown do func.() else {:noreply, socket} end end # Select the currently focused member from the dropdown @spec select_focused_member(Phoenix.LiveView.Socket.t()) :: {:noreply, Phoenix.LiveView.Socket.t()} defp select_focused_member(socket) do with index when not is_nil(index) <- socket.assigns.focused_member_index, member when not is_nil(member) <- Enum.at(socket.assigns.available_members, index) do handle_event("select_member", %{"id" => member.id}, socket) else _ -> {:noreply, socket} end end @spec assign_form(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t() defp assign_form(%{assigns: %{user: user, show_password_fields: show_password_fields}} = socket) do form = if user do # For existing users, use admin password action if password fields are shown action = if show_password_fields, do: :admin_set_password, else: :update_user AshPhoenix.Form.for_update(user, action, domain: Mv.Accounts, as: "user") else # For new users, use password registration if password fields are shown action = if show_password_fields, do: :register_with_password, else: :create_user AshPhoenix.Form.for_create(Mv.Accounts.User, action, domain: Mv.Accounts, as: "user" ) end assign(socket, form: to_form(form)) end @spec return_path(String.t(), Mv.Accounts.User.t() | nil) :: String.t() defp return_path("index", _user), do: ~p"/users" defp return_path("show", user), do: ~p"/users/#{user.id}" @spec load_initial_members(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t() defp load_initial_members(socket) do user = socket.assigns.user user_email = if user, do: user.email, else: nil members = load_members_for_linking(user_email, "") # Dropdown should ALWAYS be hidden initially # It will only show when user focuses the input field (show_member_dropdown event) socket |> assign(available_members: members) |> assign(show_member_dropdown: false) end @spec load_available_members(Phoenix.LiveView.Socket.t(), String.t()) :: Phoenix.LiveView.Socket.t() defp load_available_members(socket, query) do user = socket.assigns.user user_email = if user, do: user.email, else: nil members = load_members_for_linking(user_email, query) assign(socket, available_members: members) end @spec load_members_for_linking(String.t() | nil, String.t() | nil) :: [Mv.Membership.Member.t()] defp load_members_for_linking(user_email, search_query) do user_email_str = if user_email, do: to_string(user_email), else: nil search_query_str = if search_query && search_query != "", do: search_query, else: nil query = Mv.Membership.Member |> Ash.Query.for_read(:available_for_linking, %{ user_email: user_email_str, search_query: search_query_str }) case Ash.read(query, domain: Mv.Membership) do {:ok, members} -> # Apply email match filter if user_email is provided if user_email_str do Mv.Membership.Member.filter_by_email_match(members, user_email_str) else members end {:error, _} -> [] end end # Extract user-friendly error message from Ash.Error @spec extract_error_message(any()) :: String.t() defp extract_error_message(%Ash.Error.Invalid{errors: errors}) when is_list(errors) do # Take first error and extract message case List.first(errors) do %{message: message} when is_binary(message) -> message %{field: field, message: message} -> "#{field}: #{message}" _ -> "Unknown error" end end defp extract_error_message(error) when is_binary(error), do: error defp extract_error_message(_), do: "Unknown error" end