228 lines
7.4 KiB
Elixir
228 lines
7.4 KiB
Elixir
defmodule MvWeb.UserLive.Show do
|
|
@moduledoc """
|
|
LiveView for displaying a single user's details.
|
|
|
|
## Features
|
|
- Display user information (email, OIDC ID)
|
|
- Show authentication methods (password, OIDC)
|
|
- Display linked member account (if exists)
|
|
- Navigate to edit form
|
|
- Return to user list
|
|
|
|
## Displayed Information
|
|
- Email address
|
|
- OIDC ID (if authenticated via OIDC)
|
|
- Password authentication status
|
|
- Linked member (name and email)
|
|
|
|
## Authentication Status
|
|
Shows which authentication methods are enabled for the user:
|
|
- Password authentication (has hashed_password)
|
|
- OIDC authentication (has oidc_id)
|
|
|
|
## Navigation
|
|
- Back to user list
|
|
- Edit user (with return_to parameter for back navigation)
|
|
"""
|
|
use MvWeb, :live_view
|
|
|
|
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
|
import MvWeb.ErrorHelpers, only: [format_ash_error: 1]
|
|
|
|
@impl true
|
|
def render(assigns) do
|
|
~H"""
|
|
<Layouts.app flash={@flash} current_user={@current_user}>
|
|
<.header>
|
|
<:leading>
|
|
<.button
|
|
navigate={~p"/users"}
|
|
variant="neutral"
|
|
aria-label={gettext("Back to users list")}
|
|
>
|
|
<.icon name="hero-arrow-left" class="size-4" />
|
|
{gettext("Back")}
|
|
</.button>
|
|
</:leading>
|
|
{gettext("User")} {@user.email}
|
|
<:subtitle>{gettext("This is a user record from your database.")}</:subtitle>
|
|
|
|
<:actions>
|
|
<%= if can?(@current_user, :update, @user) do %>
|
|
<.button
|
|
variant="primary"
|
|
navigate={~p"/users/#{@user}/edit?return_to=show"}
|
|
data-testid="user-edit"
|
|
>
|
|
<.icon name="hero-pencil-square" /> {gettext("Edit User")}
|
|
</.button>
|
|
<% end %>
|
|
</:actions>
|
|
</.header>
|
|
|
|
<.list>
|
|
<:item title={gettext("Email")}>{@user.email}</:item>
|
|
<:item title={gettext("Role")}>{@user.role.name}</:item>
|
|
<:item title={gettext("Password Authentication")}>
|
|
{if MvWeb.Helpers.UserHelpers.has_password?(@user),
|
|
do: gettext("Enabled"),
|
|
else: gettext("Not enabled")}
|
|
</:item>
|
|
<:item title={gettext("OIDC")}>
|
|
{if MvWeb.Helpers.UserHelpers.has_oidc?(@user),
|
|
do: gettext("Linked"),
|
|
else: gettext("Not linked")}
|
|
</:item>
|
|
<:item title={gettext("Linked Member")}>
|
|
<%= if @user.member do %>
|
|
<.link
|
|
navigate={~p"/members/#{@user.member}"}
|
|
class="text-blue-600 underline hover:text-blue-800"
|
|
>
|
|
<.icon name="hero-users" class="inline w-4 h-4 mr-1" />
|
|
{MvWeb.Helpers.MemberHelpers.display_name(@user.member)}
|
|
</.link>
|
|
<% else %>
|
|
<span class="italic text-gray-500">{gettext("No member linked")}</span>
|
|
<% end %>
|
|
</:item>
|
|
</.list>
|
|
|
|
<%!-- Danger zone: canonical pattern (same as member show) --%>
|
|
<%= if can?(@current_user, :destroy, @user) and not Mv.Helpers.SystemActor.system_user?(@user) do %>
|
|
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
|
|
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
|
|
{gettext("Danger zone")}
|
|
</h2>
|
|
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
|
<p class="text-base-content/70 mb-4">
|
|
{gettext(
|
|
"Deleting this user cannot be undone. The user account and any linked member association will be affected."
|
|
)}
|
|
</p>
|
|
<.button
|
|
variant="danger"
|
|
phx-click="open_delete_modal"
|
|
data-testid="user-delete"
|
|
aria-label={gettext("Delete user %{email}", email: @user.email)}
|
|
>
|
|
<.icon name="hero-trash" class="size-4" />
|
|
{gettext("Delete user")}
|
|
</.button>
|
|
</div>
|
|
</section>
|
|
<% end %>
|
|
|
|
<%!-- Delete User Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%>
|
|
<%= if assigns[:show_delete_modal] do %>
|
|
<dialog id="delete-user-modal" class="modal modal-open" role="dialog" aria-labelledby="delete-user-modal-title">
|
|
<div class="modal-box">
|
|
<h3 id="delete-user-modal-title" class="text-lg font-bold">{gettext("Delete User")}</h3>
|
|
<p class="py-4">
|
|
{gettext("Are you sure you want to delete the user %{email}? This action cannot be undone.",
|
|
email: @user.email
|
|
)}
|
|
</p>
|
|
<div class="modal-action">
|
|
<.button
|
|
type="button"
|
|
variant="neutral"
|
|
phx-click="cancel_delete_modal"
|
|
phx-mounted={JS.focus()}
|
|
id="delete-user-modal-cancel"
|
|
aria-label={gettext("Cancel")}
|
|
>
|
|
{gettext("Cancel")}
|
|
</.button>
|
|
<.button
|
|
type="button"
|
|
variant="danger"
|
|
phx-click={JS.push("delete", value: %{id: @user.id})}
|
|
aria-label={gettext("Delete user")}
|
|
>
|
|
{gettext("Delete")}
|
|
</.button>
|
|
</div>
|
|
</div>
|
|
</dialog>
|
|
<% end %>
|
|
</Layouts.app>
|
|
"""
|
|
end
|
|
|
|
@impl true
|
|
def mount(%{"id" => id}, _session, socket) do
|
|
actor = current_actor(socket)
|
|
|
|
user =
|
|
Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member, :role], actor: actor)
|
|
|
|
if Mv.Helpers.SystemActor.system_user?(user) do
|
|
{:ok,
|
|
socket
|
|
|> put_flash(:error, gettext("This user cannot be viewed."))
|
|
|> push_navigate(to: ~p"/users")}
|
|
else
|
|
{:ok,
|
|
socket
|
|
|> assign(:page_title, gettext("Show User"))
|
|
|> assign(:user, user)
|
|
|> assign(:show_delete_modal, false)}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("open_delete_modal", _params, socket) do
|
|
{:noreply, assign(socket, :show_delete_modal, true)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("cancel_delete_modal", _params, socket) do
|
|
{:noreply, assign(socket, :show_delete_modal, false)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("delete", %{"id" => id}, socket) do
|
|
user = socket.assigns.user
|
|
actor = current_actor(socket)
|
|
|
|
cond do
|
|
to_string(id) != to_string(user.id) ->
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:error, gettext("User not found"))
|
|
|> assign(:show_delete_modal, false)}
|
|
|
|
Mv.Helpers.SystemActor.system_user?(user) ->
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:error, gettext("System user cannot be deleted."))
|
|
|> assign(:show_delete_modal, false)}
|
|
|
|
true ->
|
|
handle_user_delete_destroy(socket, user, actor)
|
|
end
|
|
end
|
|
|
|
defp handle_user_delete_destroy(socket, user, actor) do
|
|
case Ash.destroy(user, domain: Mv.Accounts, actor: actor) do
|
|
:ok ->
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:success, gettext("User deleted successfully"))
|
|
|> push_navigate(to: ~p"/users")}
|
|
|
|
{:error, %Ash.Error.Forbidden{}} ->
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:error, gettext("You do not have permission to delete this user"))
|
|
|> assign(:show_delete_modal, false)}
|
|
|
|
{:error, error} ->
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:error, format_ash_error(error))
|
|
|> assign(:show_delete_modal, false)}
|
|
end
|
|
end
|
|
end
|