mitgliederverwaltung/lib/mv_web/live/join_request_live/show.ex
Simon f53a3ce3cc
Some checks reported errors
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/production Build is failing
refactor: integrate approval ui review changes
2026-03-11 02:20:29 +01:00

284 lines
9.6 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

defmodule MvWeb.JoinRequestLive.Show do
@moduledoc """
LiveView for displaying a single join request and performing approve/reject actions.
## Features
- Show all request data (typed fields + form_data rendered by field)
- Approve action: transitions to :approved, creates Member
- Reject action: transitions to :rejected (no Member created)
- Actions only available when status is :submitted
## Security
- Page access controlled by CheckPagePermission plug and can_access_page? guard
- Ash policy (HasPermission) enforces JoinRequest update :all for normal_user and admin
"""
use MvWeb, :live_view
require Logger
import MvWeb.LiveHelpers, only: [current_actor: 1]
import MvWeb.Authorization
alias Mv.Constants
alias Mv.Membership
alias MvWeb.Helpers.DateFormatter
alias MvWeb.JoinRequestLive.Helpers, as: JoinRequestHelpers
alias MvWeb.Translations.MemberFields, as: MemberFieldsTranslations
@impl true
def mount(_params, _session, socket) do
if Membership.join_form_enabled?() do
{:ok,
socket
|> assign(:join_request, nil)
|> assign(:join_form_field_ids, [])
|> assign(:page_title, gettext("Join request"))}
else
{:ok, redirect(socket, to: ~p"/members")}
end
end
@impl true
def handle_params(%{"id" => id}, _url, socket) do
actor = current_actor(socket)
if Membership.join_form_enabled?() and can_access_page?(actor, "/join_requests/:id") do
case Membership.get_join_request(id, actor: actor) do
{:ok, nil} ->
{:noreply,
socket
|> put_flash(:error, gettext("Join request not found."))
|> push_navigate(to: ~p"/join_requests")}
{:ok, request} ->
field_ids = Membership.get_join_form_allowlist() |> Enum.map(& &1.id)
{:noreply,
socket
|> assign(:join_request, request)
|> assign(:join_form_field_ids, field_ids)
|> assign(:page_title, gettext("Join request %{email}", email: request.email))}
{:error, _error} ->
{:noreply,
socket
|> put_flash(:error, gettext("Failed to load join request."))
|> push_navigate(to: ~p"/join_requests")}
end
else
{:noreply, redirect(socket, to: ~p"/members")}
end
end
@impl true
def handle_event("approve", _params, socket) do
actor = current_actor(socket)
request = socket.assigns.join_request
case Membership.approve_join_request(request.id, actor: actor) do
{:ok, _approved} ->
{:noreply,
socket
|> put_flash(:info, gettext("Join request approved. Member created."))
|> push_navigate(to: ~p"/join_requests")}
{:error, error} ->
Logger.warning("Failed to approve join request #{request.id}: #{inspect(error)}")
{:noreply, put_flash(socket, :error, gettext("Failed to approve join request."))}
end
end
@impl true
def handle_event("reject", _params, socket) do
actor = current_actor(socket)
request = socket.assigns.join_request
case Membership.reject_join_request(request.id, actor: actor) do
{:ok, _rejected} ->
{:noreply,
socket
|> put_flash(:info, gettext("Join request rejected."))
|> push_navigate(to: ~p"/join_requests")}
{:error, error} ->
Logger.warning("Failed to reject join request #{request.id}: #{inspect(error)}")
{:noreply, put_flash(socket, :error, gettext("Failed to reject join request."))}
end
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
<:leading>
<.button
navigate={~p"/join_requests"}
variant="neutral"
aria-label={gettext("Back to join requests")}
>
<.icon name="hero-arrow-left" class="size-4" />
{gettext("Back")}
</.button>
</:leading>
{gettext("Join request")}
</.header>
<%= if @join_request do %>
<div class="mt-6 space-y-6 max-w-2xl">
<div>
<h2 class="text-lg font-semibold mb-2">{gettext("Request data")}</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100 space-y-2">
<.field_row label={gettext("Email")} value={@join_request.email} />
<.field_row
label={gettext("First name")}
value={@join_request.first_name}
empty_text={gettext("Not specified")}
/>
<.field_row
label={gettext("Last name")}
value={@join_request.last_name}
empty_text={gettext("Not specified")}
/>
<.field_row
label={gettext("Submitted at")}
value={DateFormatter.format_datetime(@join_request.submitted_at)}
/>
<div class="flex gap-2">
<span class="text-base-content/60 min-w-32 shrink-0">{gettext("Status")}:</span>
<span>
<.badge variant={JoinRequestHelpers.status_badge_variant(@join_request.status)}>
{JoinRequestHelpers.format_status(@join_request.status)}
</.badge>
</span>
</div>
</div>
</div>
<%= if map_size(@join_request.form_data || %{}) > 0 do %>
<div>
<h2 class="text-lg font-semibold mb-2">{gettext("Additional form data")}</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100 space-y-2">
<%= for {key, value} <- format_form_data(@join_request.form_data, @join_form_field_ids || []) do %>
<.field_row label={key} value={to_string(value)} />
<% end %>
</div>
</div>
<% end %>
<%= if @join_request.status in [:approved, :rejected] do %>
<div>
<h2 class="text-lg font-semibold mb-2">{gettext("Review information")}</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100 space-y-2">
<%= if @join_request.approved_at do %>
<.field_row
label={gettext("Approved at")}
value={DateFormatter.format_datetime(@join_request.approved_at)}
/>
<% end %>
<%= if @join_request.rejected_at do %>
<.field_row
label={gettext("Rejected at")}
value={DateFormatter.format_datetime(@join_request.rejected_at)}
/>
<% end %>
<.field_row
label={gettext("Review by")}
value={JoinRequestHelpers.reviewer_display(@join_request)}
empty_text="-"
/>
</div>
</div>
<% end %>
<%= if @join_request.status == :submitted do %>
<div class="flex flex-wrap items-center justify-between gap-3 pt-2">
<.button
variant="danger"
phx-click="reject"
data-confirm={gettext("Reject this join request?")}
data-testid="join-request-reject-btn"
>
{gettext("Reject")}
</.button>
<.button
variant="primary"
phx-click="approve"
data-confirm={gettext("Approve this join request and create a member?")}
data-testid="join-request-approve-btn"
>
{gettext("Approve")}
</.button>
</div>
<% end %>
</div>
<% end %>
</Layouts.app>
"""
end
attr :label, :string, required: true
attr :value, :any, default: nil
attr :empty_text, :string, default: nil
defp field_row(assigns) do
~H"""
<div class="flex gap-2">
<span class="text-base-content/60 min-w-32 shrink-0">{@label}:</span>
<span>
<%= if @value && @value != "" do %>
{@value}
<% else %>
<span class="text-base-content/40 italic">
{@empty_text || gettext("Not specified")}
</span>
<% end %>
</span>
</div>
"""
end
# Formats form_data for display in join-form order; legacy keys (not in current
# join_form_field_ids) are appended at the end, sorted by label for stability.
# Labels: member field keys → human-readable; UUID keys kept as-is (custom field IDs).
defp format_form_data(nil, _ordered_field_ids), do: []
defp format_form_data(form_data, ordered_field_ids) when is_map(form_data) do
member_field_strings = Constants.member_fields() |> Enum.map(&Atom.to_string/1)
# First: entries in current join form order (only keys present in form_data)
in_order =
ordered_field_ids
|> Enum.filter(&Map.has_key?(form_data, &1))
|> Enum.map(fn key ->
value = form_data[key]
label = field_key_to_label(key, member_field_strings)
{label, value}
end)
# Then: keys in form_data that are not in current settings (e.g. removed fields on old requests)
legacy_keys =
form_data
|> Map.keys()
|> Enum.reject(&(&1 in ordered_field_ids))
|> Enum.sort()
legacy_entries =
Enum.map(legacy_keys, fn key ->
label = field_key_to_label(key, member_field_strings)
{label, form_data[key]}
end)
in_order ++ legacy_entries
end
defp field_key_to_label(key, member_field_strings) when is_binary(key) do
if key in member_field_strings,
do: MemberFieldsTranslations.label(String.to_existing_atom(key)),
else: key
end
defp field_key_to_label(key, _), do: to_string(key)
end