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 <- @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) |> load_initial_members() |> assign_form()} end 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 {:noreply, assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, user_params))} 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 error from member linking/unlinking {:noreply, put_flash(socket, :error, "Failed to update member relationship: #{inspect(error)}")} 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)} 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) {: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 defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) 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 defp return_path("index", _user), do: ~p"/users" defp return_path("show", user), do: ~p"/users/#{user.id}" # Load initial members when the form is loaded or member is unlinked 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 # Load members based on search query 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 # Query available members using the Ash action 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 end