- Show alert when user has oidc_id and password section is visible. - Explains that password here does not change SSO/identity provider password.
750 lines
26 KiB
Elixir
750 lines
26 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
|
|
|
|
require Jason
|
|
|
|
alias Mv.Authorization
|
|
|
|
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
|
|
import MvWeb.Authorization, only: [can?: 3]
|
|
|
|
@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 class="max-w-xl" for={@form} id="user-form" phx-change="validate" phx-submit="save">
|
|
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
|
|
|
|
<%= if @user && @can_assign_role do %>
|
|
<div class="mt-4">
|
|
<.input
|
|
field={@form[:role_id]}
|
|
type="select"
|
|
label={gettext("Role")}
|
|
options={Enum.map(@roles, &{&1.name, &1.id})}
|
|
prompt={gettext("Select role...")}
|
|
/>
|
|
</div>
|
|
<% end %>
|
|
|
|
<!-- 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="p-4 mt-4 space-y-4 rounded-lg bg-gray-50">
|
|
<%= if @user && MvWeb.Helpers.UserHelpers.has_oidc?(@user) do %>
|
|
<div class="p-3 mb-4 border border-red-300 rounded-lg bg-red-50" role="alert">
|
|
<p class="text-sm font-semibold text-red-800">
|
|
{gettext("SSO / OIDC user")}
|
|
</p>
|
|
<p class="mt-1 text-sm text-red-700">
|
|
{gettext(
|
|
"This user is linked via SSO (Single Sign-On). A password set or changed here only affects login with email and password in this application. It does not change the password in your identity provider (e.g. Authentik). To change the SSO password, use the identity provider or your organization's IT."
|
|
)}
|
|
</p>
|
|
</div>
|
|
<% end %>
|
|
<.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="mt-1 space-y-1 text-xs list-disc list-inside">
|
|
<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 && @can_manage_member_linking do %>
|
|
<div class="p-3 mt-3 border border-orange-200 rounded bg-orange-50">
|
|
<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="p-4 mt-4 rounded-lg bg-blue-50">
|
|
<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="p-4 mt-4 rounded-lg bg-yellow-50">
|
|
<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 (admin only: only admins can link/unlink users to members) -->
|
|
<%= if @can_manage_member_linking do %>
|
|
<div class="mt-6">
|
|
<h2 class="mb-3 text-base font-semibold">{gettext("Linked Member")}</h2>
|
|
|
|
<%= if @user && @user.member && !@unlink_member do %>
|
|
<!-- Show linked member with unlink button -->
|
|
<div class="p-4 border border-green-200 rounded-lg bg-green-50">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<p class="font-medium text-green-900">
|
|
{MvWeb.Helpers.MemberHelpers.display_name(@user.member)}
|
|
</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 border border-yellow-200 rounded-lg bg-yellow-50">
|
|
<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-hook="ComboBox"
|
|
phx-focus="show_member_dropdown"
|
|
phx-change="search_members"
|
|
phx-debounce="300"
|
|
phx-window-keydown="member_dropdown_keydown"
|
|
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)}
|
|
aria-activedescendant={
|
|
if @focused_member_index,
|
|
do: "member-option-#{@focused_member_index}",
|
|
else: nil
|
|
}
|
|
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, index} <- Enum.with_index(@available_members) do %>
|
|
<div
|
|
id={"member-option-#{index}"}
|
|
role="option"
|
|
tabindex="0"
|
|
aria-selected={to_string(@focused_member_index == index)}
|
|
phx-click="select_member"
|
|
phx-value-id={member.id}
|
|
data-member-id={member.id}
|
|
class={[
|
|
"px-4 py-3 cursor-pointer border-b border-base-300 last:border-b-0",
|
|
if(@focused_member_index == index,
|
|
do: "bg-base-300",
|
|
else: "hover:bg-base-200"
|
|
)
|
|
]}
|
|
>
|
|
<p class="font-medium">
|
|
{MvWeb.Helpers.MemberHelpers.display_name(member)}
|
|
</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 border border-yellow-200 rounded bg-yellow-50">
|
|
<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="p-3 mt-2 border border-blue-200 rounded-lg bg-blue-50"
|
|
>
|
|
<p class="text-sm text-blue-800">
|
|
<strong>{gettext("Selected")}:</strong> {@selected_member_name}
|
|
</p>
|
|
<p class="mt-1 text-xs text-blue-600">
|
|
{gettext("Save to confirm linking.")}
|
|
</p>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|
|
|
|
<div class="mt-4">
|
|
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
|
{gettext("Save User")}
|
|
</.button>
|
|
<.button navigate={return_path(@return_to, @user)}>{gettext("Cancel")}</.button>
|
|
</div>
|
|
</.form>
|
|
</Layouts.app>
|
|
"""
|
|
end
|
|
|
|
@impl true
|
|
def mount(params, _session, socket) do
|
|
actor = current_actor(socket)
|
|
|
|
case load_user_or_redirect(params["id"], actor, socket) do
|
|
{:redirect, socket} ->
|
|
{:ok, socket}
|
|
|
|
{:ok, user} ->
|
|
mount_continue(user, params, socket)
|
|
end
|
|
end
|
|
|
|
defp load_user_or_redirect(nil, _actor, _socket), do: {:ok, nil}
|
|
|
|
defp load_user_or_redirect(id, actor, socket) do
|
|
user = Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member], actor: actor)
|
|
|
|
if Mv.Helpers.SystemActor.system_user?(user) do
|
|
{:redirect,
|
|
socket
|
|
|> put_flash(:error, gettext("This user cannot be edited."))
|
|
|> push_navigate(to: ~p"/users")}
|
|
else
|
|
{:ok, user}
|
|
end
|
|
end
|
|
|
|
defp mount_continue(user, params, socket) do
|
|
actor = current_actor(socket)
|
|
action = if is_nil(user), do: gettext("New"), else: gettext("Edit")
|
|
page_title = action <> " " <> gettext("User")
|
|
|
|
# Only admins can link/unlink users to members (permission docs; prevents privilege escalation).
|
|
can_manage_member_linking = can?(actor, :destroy, Mv.Accounts.User)
|
|
# Only admins can assign user roles (Role update permission).
|
|
can_assign_role = can?(actor, :update, Mv.Authorization.Role)
|
|
roles = if can_assign_role, do: load_roles(actor), else: []
|
|
|
|
{:ok,
|
|
socket
|
|
|> assign(:return_to, return_to(params["return_to"]))
|
|
|> assign(user: user)
|
|
|> assign(:page_title, page_title)
|
|
|> assign(:can_manage_member_linking, can_manage_member_linking)
|
|
|> assign(:can_assign_role, can_assign_role)
|
|
|> assign(:roles, roles)
|
|
|> 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
|
|
|
|
@impl true
|
|
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; only when member linking UI is shown)
|
|
socket =
|
|
if Map.has_key?(user_params, "email") and socket.assigns[:can_manage_member_linking] do
|
|
user_email = user_params["email"]
|
|
members = load_members_for_linking(user_email, socket.assigns.member_search_query, socket)
|
|
|
|
assign(socket, form: validated_form, available_members: members)
|
|
else
|
|
assign(socket, form: validated_form)
|
|
end
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("save", %{"user" => user_params}, socket) do
|
|
actor = current_actor(socket)
|
|
|
|
# Include current member in params when not linking/unlinking so update_user's
|
|
# manage_relationship(on_missing: :unrelate) does not accidentally unlink.
|
|
user_params = params_with_member_if_unchanged(socket, user_params)
|
|
|
|
case submit_form(socket.assigns.form, user_params, actor) do
|
|
{:ok, user} ->
|
|
handle_member_linking(socket, user, actor)
|
|
|
|
{:error, form} ->
|
|
{:noreply, assign(socket, form: form)}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("show_member_dropdown", _params, socket) do
|
|
{:noreply, assign(socket, show_member_dropdown: true)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("hide_member_dropdown", _params, socket) do
|
|
{:noreply, assign(socket, show_member_dropdown: false, focused_member_index: nil)}
|
|
end
|
|
|
|
@impl true
|
|
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
|
|
|
|
@impl true
|
|
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
|
|
|
|
@impl true
|
|
def handle_event("member_dropdown_keydown", %{"key" => "Enter"}, socket) do
|
|
return_if_dropdown_closed(socket, fn ->
|
|
select_focused_member(socket)
|
|
end)
|
|
end
|
|
|
|
@impl true
|
|
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
|
|
|
|
@impl true
|
|
def handle_event("member_dropdown_keydown", _params, socket) do
|
|
# Ignore other keys
|
|
{:noreply, socket}
|
|
end
|
|
|
|
@impl true
|
|
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
|
|
|
|
@impl true
|
|
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: MvWeb.Helpers.MemberHelpers.display_name(selected_member),
|
|
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(:focused_member_index, nil)
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("unlink_member", _params, socket) do
|
|
socket =
|
|
socket
|
|
|> assign(:selected_member_id, nil)
|
|
|> assign(:selected_member_name, nil)
|
|
|> assign(:unlink_member, true)
|
|
|> assign(:show_member_dropdown, false)
|
|
|> assign(:focused_member_index, nil)
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
defp handle_member_linking(socket, user, actor) do
|
|
result = perform_member_link_action(socket, user, actor)
|
|
|
|
case result do
|
|
{:ok, updated_user} ->
|
|
handle_save_success(socket, updated_user)
|
|
|
|
{:error, error} ->
|
|
handle_member_link_error(socket, error)
|
|
end
|
|
end
|
|
|
|
defp perform_member_link_action(socket, user, actor) do
|
|
# Only admins may link/unlink (backend policy also restricts update_user; UI must not call it).
|
|
if can?(actor, :destroy, Mv.Accounts.User) do
|
|
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}},
|
|
actor: actor
|
|
)
|
|
|
|
# Unlink flag is set
|
|
socket.assigns[:unlink_member] ->
|
|
Mv.Accounts.update_user(user, %{member: nil}, actor: actor)
|
|
|
|
# No changes to member relationship
|
|
true ->
|
|
{:ok, user}
|
|
end
|
|
else
|
|
{:ok, user}
|
|
end
|
|
end
|
|
|
|
defp handle_save_success(socket, updated_user) do
|
|
notify_parent({:saved, updated_user})
|
|
|
|
action = get_action_name(socket.assigns.form.source.type)
|
|
|
|
socket =
|
|
socket
|
|
|> put_flash(:info, gettext("User %{action} successfully", action: action))
|
|
|> push_navigate(to: return_path(socket.assigns.return_to, updated_user))
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
defp get_action_name(:create), do: gettext("created")
|
|
defp get_action_name(:update), do: gettext("updated")
|
|
defp get_action_name(other), do: to_string(other)
|
|
|
|
# When user has a linked member and we are not linking/unlinking, include current member in params
|
|
# so update_user's manage_relationship(on_missing: :unrelate) does not unlink the member.
|
|
defp params_with_member_if_unchanged(socket, params) do
|
|
user = socket.assigns.user
|
|
linking = socket.assigns.selected_member_id
|
|
unlinking = socket.assigns[:unlink_member]
|
|
|
|
if user && user.member_id && !linking && !unlinking do
|
|
Map.put(params, "member", %{"id" => user.member_id})
|
|
else
|
|
params
|
|
end
|
|
end
|
|
|
|
defp handle_member_link_error(socket, error) do
|
|
error_message = extract_error_message(error)
|
|
|
|
{:noreply,
|
|
put_flash(
|
|
socket,
|
|
:error,
|
|
gettext("Failed to link member: %{error}", error: error_message)
|
|
)}
|
|
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,
|
|
can_manage_member_linking: can_manage_member_linking,
|
|
can_assign_role: can_assign_role
|
|
}
|
|
} = socket
|
|
) do
|
|
actor = current_actor(socket)
|
|
|
|
form =
|
|
if user do
|
|
# For existing users: admin uses update_user (email + member + role_id); non-admin uses update (email only).
|
|
# Password change uses admin_set_password for both.
|
|
action =
|
|
cond do
|
|
show_password_fields -> :admin_set_password
|
|
can_manage_member_linking or can_assign_role -> :update_user
|
|
true -> :update
|
|
end
|
|
|
|
form =
|
|
AshPhoenix.Form.for_update(user, action, domain: Mv.Accounts, as: "user", actor: actor)
|
|
|
|
# Ensure role_id is always included on submit when role dropdown is shown (AshPhoenix.Form
|
|
# only submits keys in touched_forms; marking as touched avoids role change being dropped).
|
|
if can_assign_role and action == :update_user do
|
|
AshPhoenix.Form.touch(form, [:role_id])
|
|
else
|
|
form
|
|
end
|
|
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",
|
|
actor: actor
|
|
)
|
|
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, "", socket)
|
|
|
|
# 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, socket)
|
|
assign(socket, available_members: members)
|
|
end
|
|
|
|
@spec load_members_for_linking(String.t() | nil, String.t() | nil, Phoenix.LiveView.Socket.t()) ::
|
|
[
|
|
Mv.Membership.Member.t()
|
|
]
|
|
defp load_members_for_linking(user_email, search_query, socket) 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
|
|
})
|
|
|
|
actor = current_actor(socket)
|
|
|
|
# Early return if no actor (prevents exceptions in unauthenticated tests)
|
|
if is_nil(actor) do
|
|
[]
|
|
else
|
|
case Ash.read(query, domain: Mv.Membership, actor: actor) do
|
|
{:ok, members} -> apply_email_filter(members, user_email_str)
|
|
{:error, _} -> []
|
|
end
|
|
end
|
|
end
|
|
|
|
@spec apply_email_filter([Mv.Membership.Member.t()], String.t() | nil) ::
|
|
[Mv.Membership.Member.t()]
|
|
defp apply_email_filter(members, nil), do: members
|
|
|
|
defp apply_email_filter(members, user_email_str) when is_binary(user_email_str) do
|
|
Mv.Membership.Member.filter_by_email_match(members, user_email_str)
|
|
end
|
|
|
|
@spec load_roles(any()) :: [Mv.Authorization.Role.t()]
|
|
defp load_roles(actor) do
|
|
case Authorization.list_roles(actor: actor) do
|
|
{:ok, roles} -> roles
|
|
{: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}"
|
|
_ -> gettext("Unknown error")
|
|
end
|
|
end
|
|
|
|
defp extract_error_message(error) when is_binary(error), do: error
|
|
defp extract_error_message(_), do: gettext("Unknown error")
|
|
end
|