458 lines
16 KiB
Elixir
458 lines
16 KiB
Elixir
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"""
|
|
<Layouts.app flash={@flash} current_user={@current_user}>
|
|
<.header>
|
|
{@page_title}
|
|
<:subtitle>{gettext("Use this form to manage user records in your database.")}</:subtitle>
|
|
</.header>
|
|
|
|
<.form for={@form} id="user-form" phx-change="validate" phx-submit="save">
|
|
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
|
|
|
|
<!-- Password Section -->
|
|
<div class="mt-6">
|
|
<label class="flex items-center space-x-2">
|
|
<input
|
|
type="checkbox"
|
|
name="set_password"
|
|
phx-click="toggle_password_section"
|
|
checked={@show_password_fields}
|
|
class="checkbox checkbox-sm"
|
|
/>
|
|
<span class="text-sm font-medium">
|
|
{if @user, do: gettext("Change Password"), else: gettext("Set Password")}
|
|
</span>
|
|
</label>
|
|
|
|
<%= if @show_password_fields do %>
|
|
<div class="mt-4 space-y-4 p-4 bg-gray-50 rounded-lg">
|
|
<.input
|
|
field={@form[:password]}
|
|
label={gettext("Password")}
|
|
type="password"
|
|
required
|
|
autocomplete="new-password"
|
|
/>
|
|
|
|
<!-- Only show password confirmation for new users (register_with_password) -->
|
|
<%= if !@user do %>
|
|
<.input
|
|
field={@form[:password_confirmation]}
|
|
label={gettext("Confirm Password")}
|
|
type="password"
|
|
required
|
|
autocomplete="new-password"
|
|
/>
|
|
<% end %>
|
|
|
|
<div class="text-sm text-gray-600">
|
|
<p><strong>{gettext("Password requirements")}:</strong></p>
|
|
<ul class="list-disc list-inside text-xs mt-1 space-y-1">
|
|
<li>{gettext("At least 8 characters")}</li>
|
|
<li>{gettext("Include both letters and numbers")}</li>
|
|
<li>{gettext("Consider using special characters")}</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<%= if @user do %>
|
|
<div class="mt-3 p-3 bg-orange-50 border border-orange-200 rounded">
|
|
<p class="text-sm text-orange-800">
|
|
<strong>{gettext("Admin Note")}:</strong> {gettext(
|
|
"As an administrator, you can directly set a new password for this user using the same secure Ash Authentication system."
|
|
)}
|
|
</p>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
<% else %>
|
|
<%= if @user do %>
|
|
<div class="mt-4 p-4 bg-blue-50 rounded-lg">
|
|
<p class="text-sm text-blue-800">
|
|
<strong>{gettext("Note")}:</strong> {gettext(
|
|
"Check 'Change Password' above to set a new password for this user."
|
|
)}
|
|
</p>
|
|
</div>
|
|
<% else %>
|
|
<div class="mt-4 p-4 bg-yellow-50 rounded-lg">
|
|
<p class="text-sm text-yellow-800">
|
|
<strong>{gettext("Note")}:</strong> {gettext(
|
|
"User will be created without a password. Check 'Set Password' to add one."
|
|
)}
|
|
</p>
|
|
</div>
|
|
<% end %>
|
|
<% end %>
|
|
</div>
|
|
|
|
<!-- Member Linking Section -->
|
|
<div class="mt-6">
|
|
<h2 class="text-base font-semibold mb-3">{gettext("Linked Member")}</h2>
|
|
|
|
<%= if @user && @user.member && !@unlink_member do %>
|
|
<!-- Show linked member with unlink button -->
|
|
<div class="p-4 bg-green-50 border border-green-200 rounded-lg">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<p class="font-medium text-green-900">
|
|
{@user.member.first_name} {@user.member.last_name}
|
|
</p>
|
|
<p class="text-sm text-green-700">{@user.member.email}</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
phx-click="unlink_member"
|
|
class="btn btn-sm btn-error"
|
|
>
|
|
{gettext("Unlink Member")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<% else %>
|
|
<%= if @unlink_member do %>
|
|
<!-- Show unlink pending message -->
|
|
<div class="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
|
<p class="text-sm text-yellow-800">
|
|
<strong>{gettext("Unlinking scheduled")}:</strong> {gettext(
|
|
"Member will be unlinked when you save. Cannot select new member until saved."
|
|
)}
|
|
</p>
|
|
</div>
|
|
<% end %>
|
|
<!-- Show member search/selection for unlinked users -->
|
|
<div class="space-y-3">
|
|
<div class="relative">
|
|
<input
|
|
type="text"
|
|
id="member-search-input"
|
|
role="combobox"
|
|
phx-focus="show_member_dropdown"
|
|
phx-change="search_members"
|
|
phx-debounce="300"
|
|
value={@member_search_query}
|
|
placeholder={gettext("Search for a member to link...")}
|
|
class="w-full input"
|
|
name="member_search"
|
|
disabled={@unlink_member}
|
|
aria-label={gettext("Search for member to link")}
|
|
aria-describedby={if @selected_member_name, do: "member-selected", else: nil}
|
|
aria-autocomplete="list"
|
|
aria-controls="member-dropdown"
|
|
aria-expanded={to_string(@show_member_dropdown)}
|
|
autocomplete="off"
|
|
/>
|
|
|
|
<%= if length(@available_members) > 0 do %>
|
|
<div
|
|
id="member-dropdown"
|
|
role="listbox"
|
|
aria-label={gettext("Available members")}
|
|
class={"absolute z-10 w-full mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg max-h-60 overflow-auto #{if !@show_member_dropdown, do: "hidden"}"}
|
|
phx-click-away="hide_member_dropdown"
|
|
>
|
|
<%= for member <- @available_members do %>
|
|
<div
|
|
role="option"
|
|
tabindex="0"
|
|
aria-selected="false"
|
|
phx-click="select_member"
|
|
phx-value-id={member.id}
|
|
data-member-id={member.id}
|
|
class="px-4 py-3 hover:bg-base-200 cursor-pointer border-b border-base-300 last:border-b-0"
|
|
>
|
|
<p class="font-medium">{member.first_name} {member.last_name}</p>
|
|
<p class="text-sm text-base-content/70">{member.email}</p>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
|
|
<%= if @user && @user.email && @available_members != [] && Enum.all?(@available_members, &(&1.email == to_string(@user.email))) do %>
|
|
<div class="p-3 bg-yellow-50 border border-yellow-200 rounded">
|
|
<p class="text-sm text-yellow-800">
|
|
<strong>{gettext("Note")}:</strong> {gettext(
|
|
"A member with this email already exists. To link with a different member, please change one of the email addresses first."
|
|
)}
|
|
</p>
|
|
</div>
|
|
<% end %>
|
|
|
|
<%= if @selected_member_id && @selected_member_name do %>
|
|
<div
|
|
id="member-selected"
|
|
class="mt-2 p-3 bg-blue-50 border border-blue-200 rounded-lg"
|
|
>
|
|
<p class="text-sm text-blue-800">
|
|
<strong>{gettext("Selected")}:</strong> {@selected_member_name}
|
|
</p>
|
|
<p class="text-xs text-blue-600 mt-1">
|
|
{gettext("Save to confirm linking.")}
|
|
</p>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
|
|
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
|
{gettext("Save User")}
|
|
</.button>
|
|
<.button navigate={return_path(@return_to, @user)}>{gettext("Cancel")}</.button>
|
|
</.form>
|
|
</Layouts.app>
|
|
"""
|
|
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
|