feat: add approval ui for join requests
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Simon 2026-03-11 02:04:03 +01:00
parent 50433e607f
commit 86d9242d83
Signed by: simon
GPG key ID: 40E7A58C4AA1EDB2
22 changed files with 1624 additions and 12 deletions

View file

@ -44,7 +44,16 @@ defmodule MvWeb.Layouts do
def app(assigns) do
club_name = get_club_name()
assigns = assign(assigns, :club_name, club_name)
join_form_enabled = get_join_form_enabled()
unprocessed_join_requests_count =
get_unprocessed_join_requests_count(assigns.current_user, join_form_enabled)
assigns =
assigns
|> assign(:club_name, club_name)
|> assign(:join_form_enabled, join_form_enabled)
|> assign(:unprocessed_join_requests_count, unprocessed_join_requests_count)
~H"""
<%= if @current_user do %>
@ -78,7 +87,13 @@ defmodule MvWeb.Layouts do
</div>
<div class="drawer-side z-40">
<.sidebar current_user={@current_user} club_name={@club_name} mobile={false} />
<.sidebar
current_user={@current_user}
club_name={@club_name}
join_form_enabled={@join_form_enabled}
unprocessed_join_requests_count={@unprocessed_join_requests_count}
mobile={false}
/>
</div>
</div>
<% else %>
@ -121,6 +136,20 @@ defmodule MvWeb.Layouts do
end
end
defp get_join_form_enabled do
case Mv.Membership.get_settings() do
{:ok, %{join_form_enabled: true}} -> true
_ -> false
end
end
defp get_unprocessed_join_requests_count(nil, _), do: 0
defp get_unprocessed_join_requests_count(_user, false), do: 0
defp get_unprocessed_join_requests_count(user, true) do
Mv.Membership.count_submitted_join_requests(actor: user)
end
@doc """
Shows the flash group with standard titles and content.

View file

@ -8,6 +8,15 @@ defmodule MvWeb.Layouts.Sidebar do
attr :current_user, :map, default: nil, doc: "The current user"
attr :club_name, :string, required: true, doc: "The name of the club"
attr :join_form_enabled, :boolean,
default: false,
doc: "Whether the public join form is enabled in settings"
attr :unprocessed_join_requests_count, :integer,
default: 0,
doc: "Count of submitted (unprocessed) join requests for sidebar indicator"
attr :mobile, :boolean, default: false, doc: "Whether this is mobile view"
def sidebar(assigns) do
@ -96,6 +105,15 @@ defmodule MvWeb.Layouts.Sidebar do
/>
<% end %>
<%= if @join_form_enabled and can_access_page?(@current_user, PagePaths.join_requests()) do %>
<.menu_item
href={~p"/join_requests"}
icon="hero-inbox-arrow-down"
label={gettext("Join requests")}
indicator_dot={@unprocessed_join_requests_count > 0}
/>
<% end %>
<%= if admin_menu_visible?(@current_user) do %>
<.menu_group
icon="hero-cog-6-tooth"
@ -137,6 +155,10 @@ defmodule MvWeb.Layouts.Sidebar do
attr :icon, :string, required: true, doc: "Heroicon name"
attr :label, :string, required: true, doc: "Menu item label"
attr :indicator_dot, :boolean,
default: false,
doc: "Show a small dot on the icon (e.g. for unprocessed items)"
defp menu_item(assigns) do
~H"""
<li role="none">
@ -146,7 +168,16 @@ defmodule MvWeb.Layouts.Sidebar do
data-tip={@label}
role="menuitem"
>
<.icon name={@icon} class="size-5 shrink-0" aria-hidden="true" />
<span class="relative shrink-0">
<.icon name={@icon} class="size-5 shrink-0" aria-hidden="true" />
<%= if @indicator_dot do %>
<span
class="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-primary"
aria-hidden="true"
>
</span>
<% end %>
</span>
<span class="menu-label">{@label}</span>
</.link>
</li>

View file

@ -24,4 +24,23 @@ defmodule MvWeb.Helpers.DateFormatter do
def format_date(nil), do: ""
def format_date(_), do: "Invalid date"
@doc """
Formats a DateTime struct to European format (dd.mm.yyyy HH:MM).
## Examples
iex> MvWeb.Helpers.DateFormatter.format_datetime(~U[2024-03-15 10:30:00Z])
"15.03.2024 10:30"
iex> MvWeb.Helpers.DateFormatter.format_datetime(nil)
""
"""
def format_datetime(%DateTime{} = dt) do
Calendar.strftime(dt, "%d.%m.%Y %H:%M")
end
def format_datetime(nil), do: ""
def format_datetime(_), do: "Invalid datetime"
end

View file

@ -0,0 +1,200 @@
defmodule MvWeb.JoinRequestLive.Index do
@moduledoc """
LiveView for listing and reviewing join requests (approval UI, Step 2).
## Features
- List join requests filtered by status (default: submitted)
- Navigate to detail view for approve/reject actions
- Accessible to normal_user and admin roles only
## Security
- Page access controlled by CheckPagePermission plug and can_access_page? guard
- Ash policy (HasPermission) enforces JoinRequest read :all for normal_user and admin
"""
use MvWeb, :live_view
require Logger
import MvWeb.LiveHelpers, only: [current_actor: 1]
import MvWeb.Authorization
alias Mv.Membership
alias MvWeb.Helpers.DateFormatter
@impl true
def mount(_params, _session, socket) do
actor = current_actor(socket)
cond do
not join_form_enabled?() ->
{:ok, redirect(socket, to: ~p"/members")}
not can_access_page?(actor, "/join_requests") ->
{:ok, redirect(socket, to: ~p"/members")}
true ->
{:ok, load_join_requests(socket, actor)}
end
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
{gettext("Join requests")}
</.header>
<div class="mt-6 space-y-8 max-w-4xl">
<div>
<h2 class="text-lg font-semibold mb-3">{gettext("Open requests")}</h2>
<%= if Enum.empty?(@join_requests) do %>
<div class="text-center py-12 border border-base-300 rounded-lg bg-base-100">
<p class="text-base-content/60 italic">{gettext("No submitted join requests")}</p>
</div>
<% else %>
<.table
id="join-requests-table"
rows={@join_requests}
row_id={fn req -> "join-request-#{req.id}" end}
row_click={fn req -> JS.navigate(~p"/join_requests/#{req.id}") end}
row_tooltip={gettext("Click for details")}
>
<:col :let={req} label={gettext("Submitted at")}>
<%= if req.submitted_at do %>
{DateFormatter.format_datetime(req.submitted_at)}
<% else %>
<.empty_cell sr_text={gettext("Not submitted yet")} />
<% end %>
</:col>
<:col :let={req} label={gettext("First name")}>
<.maybe_value value={req.first_name} empty_sr_text={gettext("Not specified")}>
{req.first_name}
</.maybe_value>
</:col>
<:col :let={req} label={gettext("Last name")}>
<.maybe_value value={req.last_name} empty_sr_text={gettext("Not specified")}>
{req.last_name}
</.maybe_value>
</:col>
<:col :let={req} label={gettext("Email")}>
{req.email}
</:col>
<:col :let={req} label={gettext("Status")}>
<.badge variant={status_badge_variant(req.status)}>
{format_status(req.status)}
</.badge>
</:col>
</.table>
<% end %>
</div>
<div>
<h2 class="text-lg font-semibold mb-3">{gettext("History")}</h2>
<%= if Enum.empty?(@join_requests_history) do %>
<div class="text-center py-12 border border-base-300 rounded-lg bg-base-100">
<p class="text-base-content/60 italic">
{gettext("No approved or rejected requests yet")}
</p>
</div>
<% else %>
<.table
id="join-requests-history-table"
rows={@join_requests_history}
row_id={fn req -> "join-request-history-#{req.id}" end}
row_click={fn req -> JS.navigate(~p"/join_requests/#{req.id}") end}
row_tooltip={gettext("Click for details")}
>
<:col :let={req} label={gettext("Email")}>
{req.email}
</:col>
<:col :let={req} label={gettext("First name")}>
<.maybe_value value={req.first_name} empty_sr_text={gettext("Not specified")}>
{req.first_name}
</.maybe_value>
</:col>
<:col :let={req} label={gettext("Last name")}>
<.maybe_value value={req.last_name} empty_sr_text={gettext("Not specified")}>
{req.last_name}
</.maybe_value>
</:col>
<:col :let={req} label={gettext("Status")}>
<.badge variant={status_badge_variant(req.status)}>
{format_status(req.status)}
</.badge>
</:col>
<:col :let={req} label={gettext("Reviewed at")}>
{review_date(req)}
</:col>
<:col :let={req} label={gettext("Review by")}>
{reviewer_display(req)}
</:col>
</.table>
<% end %>
</div>
</div>
</Layouts.app>
"""
end
defp join_form_enabled? do
case Membership.get_settings() do
{:ok, %{join_form_enabled: true}} -> true
_ -> false
end
end
defp load_join_requests(socket, actor) do
socket =
case Membership.list_join_requests(actor: actor, status: :submitted) do
{:ok, requests} ->
assign(socket, :join_requests, requests)
{:error, error} ->
Logger.warning("Failed to load join requests: #{inspect(error)}")
assign(socket, :join_requests, [])
end
socket =
case Membership.list_join_requests_history(actor: actor) do
{:ok, history} ->
assign(socket, :join_requests_history, history)
{:error, error} ->
Logger.warning("Failed to load join requests history: #{inspect(error)}")
assign(socket, :join_requests_history, [])
end
assign(socket, :page_title, gettext("Join requests"))
end
defp format_status(:pending_confirmation), do: gettext("Pending confirmation")
defp format_status(:submitted), do: gettext("Submitted")
defp format_status(:approved), do: gettext("Approved")
defp format_status(:rejected), do: gettext("Rejected")
defp format_status(other), do: to_string(other)
defp status_badge_variant(:submitted), do: :info
defp status_badge_variant(:approved), do: :success
defp status_badge_variant(:rejected), do: :error
defp status_badge_variant(_), do: :neutral
defp review_date(req) do
date =
case req.status do
:approved -> req.approved_at
:rejected -> req.rejected_at
_ -> nil
end
if date, do: DateFormatter.format_datetime(date), else: ""
end
defp reviewer_display(req) do
case req.reviewed_by_user do
nil -> ""
%{email: email} when not is_nil(email) -> to_string(email) |> String.trim()
_ -> ""
end
end
end

View file

@ -0,0 +1,320 @@
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
@impl true
def mount(_params, _session, socket) do
if 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 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
defp join_form_enabled? do
case Membership.get_settings() do
{:ok, %{join_form_enabled: true}} -> true
_ -> false
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={status_badge_variant(@join_request.status)}>
{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={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
defp format_status(:pending_confirmation), do: gettext("Pending confirmation")
defp format_status(:submitted), do: gettext("Submitted")
defp format_status(:approved), do: gettext("Approved")
defp format_status(:rejected), do: gettext("Rejected")
defp format_status(other), do: to_string(other)
defp status_badge_variant(:submitted), do: :info
defp status_badge_variant(:approved), do: :success
defp status_badge_variant(:rejected), do: :error
defp status_badge_variant(_), do: :neutral
defp reviewer_display(%{reviewed_by_user: user}) do
case user do
nil ->
nil
%{email: email} when not is_nil(email) ->
s = to_string(email) |> String.trim()
if s == "", do: nil, else: s
_ ->
nil
end
end
defp reviewer_display(_), do: nil
# 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: humanize_field(key), else: key
end
defp field_key_to_label(key, _), do: to_string(key)
defp humanize_field(key) when is_binary(key) do
key
|> String.replace("_", " ")
|> String.capitalize()
end
end

View file

@ -9,6 +9,7 @@ defmodule MvWeb.PagePaths do
# Sidebar top-level menu paths
@members "/members"
@statistics "/statistics"
@join_requests "/join_requests"
# Administration submenu paths (all must match router)
@users "/users"
@ -35,6 +36,9 @@ defmodule MvWeb.PagePaths do
@doc "Path for Statistics page (sidebar and page permission check)."
def statistics, do: @statistics
@doc "Path for Join Requests approval UI (sidebar and page permission check)."
def join_requests, do: @join_requests
@doc "Paths for Administration menu; show group if user can access any of these."
def admin_menu_paths, do: @admin_page_paths

View file

@ -83,6 +83,10 @@ defmodule MvWeb.Router do
live "/groups/:slug", GroupLive.Show, :show
live "/groups/:slug/edit", GroupLive.Form, :edit
# Join Request Approval (normal_user and admin)
live "/join_requests", JoinRequestLive.Index, :index
live "/join_requests/:id", JoinRequestLive.Show, :show
# Role Management (Admin only)
live "/admin/roles", RoleLive.Index, :index
live "/admin/roles/new", RoleLive.Form, :new