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

@ -40,6 +40,13 @@ defmodule Mv.Membership.JoinRequest do
change Mv.Membership.JoinRequest.Changes.FilterFormDataByAllowlist
end
# Internal/seeding only: create with status submitted (no policy allows; use authorize?: false).
create :create_submitted do
description "Create a join request with status submitted (seeds, internal use only)"
accept [:email, :first_name, :last_name, :form_data, :schema_version]
change Mv.Membership.JoinRequest.Changes.SetSubmittedForSeeding
end
read :get_by_confirmation_token_hash do
description "Find a join request by confirmation token hash (for confirm flow only)"
argument :confirmation_token_hash, :string, allow_nil?: false
@ -56,25 +63,64 @@ defmodule Mv.Membership.JoinRequest do
change Mv.Membership.JoinRequest.Changes.ConfirmRequest
end
update :approve do
description "Approve a submitted join request and promote to Member"
require_atomic? false
change Mv.Membership.JoinRequest.Changes.ApproveRequest
end
update :reject do
description "Reject a submitted join request"
require_atomic? false
change Mv.Membership.JoinRequest.Changes.RejectRequest
end
end
policies do
policy action(:submit) do
# Use :strict so unauthorized access returns Forbidden (not empty list).
# Default :filter would silently return [] for unauthorized reads instead of Forbidden.
default_access_type :strict
# Public actions: bypass so nil actor is immediately authorized (skips all remaining policies).
# Using bypass (not policy) avoids AND-combination with the read policy below.
bypass action(:submit) do
description "Allow unauthenticated submit (public join form)"
authorize_if Mv.Authorization.Checks.ActorIsNil
end
policy action(:get_by_confirmation_token_hash) do
bypass action(:get_by_confirmation_token_hash) do
description "Allow unauthenticated lookup by token hash for confirm"
authorize_if Mv.Authorization.Checks.ActorIsNil
end
policy action(:confirm) do
bypass action(:confirm) do
description "Allow unauthenticated confirm (confirmation link click)"
authorize_if Mv.Authorization.Checks.ActorIsNil
end
# Default read/destroy: no policy for actor nil → Forbidden
# READ: bypass for authorized roles (normal_user, admin).
# Uses a SimpleCheck (HasJoinRequestAccess) to avoid HasPermission.auto_filter returning
# expr(false), which would silently produce an empty list instead of Forbidden for
# unauthorized actors. See docs/policy-bypass-vs-haspermission.md.
# Unauthorized actors fall through to no matching policy → Ash default deny (Forbidden).
bypass action_type(:read) do
description "Allow normal_user and admin to read join requests (SimpleCheck bypass)"
authorize_if Mv.Authorization.Checks.HasJoinRequestAccess
end
# Approve/Reject: only actors with JoinRequest update permission
policy action(:approve) do
description "Allow authenticated users with JoinRequest update permission to approve"
authorize_if Mv.Authorization.Checks.HasPermission
end
policy action(:reject) do
description "Allow authenticated users with JoinRequest update permission to reject"
authorize_if Mv.Authorization.Checks.HasPermission
end
end
validations do
@ -135,6 +181,13 @@ defmodule Mv.Membership.JoinRequest do
update_timestamp :updated_at
end
relationships do
belongs_to :reviewed_by_user, Mv.Accounts.User do
define_attribute? false
source_attribute :reviewed_by_user_id
end
end
# Public helpers (used by SetConfirmationToken change and domain confirm_join_request)
@doc """

View file

@ -0,0 +1,37 @@
defmodule Mv.Membership.JoinRequest.Changes.ApproveRequest do
@moduledoc """
Sets the join request to approved and records the reviewer.
Only transitions from :submitted status. If already approved, returns error
(idempotency guard via status validation). Promotion to Member is handled
by the domain function approve_join_request/2 after calling this action.
"""
use Ash.Resource.Change
@spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t()
def change(changeset, _opts, context) do
current_status = Ash.Changeset.get_data(changeset, :status)
if current_status == :submitted do
reviewed_by_id = actor_id(context.actor)
changeset
|> Ash.Changeset.force_change_attribute(:status, :approved)
|> Ash.Changeset.force_change_attribute(:approved_at, DateTime.utc_now())
|> Ash.Changeset.force_change_attribute(:reviewed_by_user_id, reviewed_by_id)
else
Ash.Changeset.add_error(changeset,
field: :status,
message: "can only approve a submitted join request (current status: #{current_status})"
)
end
end
defp actor_id(nil), do: nil
defp actor_id(actor) when is_map(actor) do
Map.get(actor, :id) || Map.get(actor, "id")
end
defp actor_id(_), do: nil
end

View file

@ -0,0 +1,36 @@
defmodule Mv.Membership.JoinRequest.Changes.RejectRequest do
@moduledoc """
Sets the join request to rejected and records the reviewer.
Only transitions from :submitted status. Returns an error for any other status.
No reason field in MVP; audit fields (rejected_at, reviewed_by_user_id) are set.
"""
use Ash.Resource.Change
@spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t()
def change(changeset, _opts, context) do
current_status = Ash.Changeset.get_data(changeset, :status)
if current_status == :submitted do
reviewed_by_id = actor_id(context.actor)
changeset
|> Ash.Changeset.force_change_attribute(:status, :rejected)
|> Ash.Changeset.force_change_attribute(:rejected_at, DateTime.utc_now())
|> Ash.Changeset.force_change_attribute(:reviewed_by_user_id, reviewed_by_id)
else
Ash.Changeset.add_error(changeset,
field: :status,
message: "can only reject a submitted join request (current status: #{current_status})"
)
end
end
defp actor_id(nil), do: nil
defp actor_id(actor) when is_map(actor) do
Map.get(actor, :id) || Map.get(actor, "id")
end
defp actor_id(_), do: nil
end

View file

@ -0,0 +1,15 @@
defmodule Mv.Membership.JoinRequest.Changes.SetSubmittedForSeeding do
@moduledoc """
Sets status to :submitted and submitted_at for seed/internal creation.
Used only by the :create_submitted action (e.g. seeds, no policy allows it for normal actors).
"""
use Ash.Resource.Change
@spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t()
def change(changeset, _opts, _context) do
changeset
|> Ash.Changeset.force_change_attribute(:status, :submitted)
|> Ash.Changeset.force_change_attribute(:submitted_at, DateTime.utc_now())
end
end

View file

@ -87,7 +87,8 @@ defmodule Mv.Membership do
end
resource Mv.Membership.JoinRequest do
# submit_join_request/2 implemented as custom function below (create + send email)
# Public submit/confirm and approval domain functions are implemented as custom
# functions below to handle cross-resource operations (Member promotion on approve).
end
end
@ -507,4 +508,202 @@ defmodule Mv.Membership do
defp expired?(nil), do: true
defp expired?(expires_at), do: DateTime.compare(expires_at, DateTime.utc_now()) == :lt
# ---------------------------------------------------------------------------
# Step 2: Approval domain functions
# ---------------------------------------------------------------------------
@doc """
Lists join requests, optionally filtered by status.
## Options
- `:actor` - Required. The actor for authorization (normal_user or admin).
- `:status` - Optional atom to filter by status (default: `:submitted`).
Pass `:all` to return requests of all statuses.
## Returns
- `{:ok, list}` - List of JoinRequests
- `{:error, error}` - Authorization or query error
"""
@spec list_join_requests(keyword()) :: {:ok, [JoinRequest.t()]} | {:error, term()}
def list_join_requests(opts \\ []) do
actor = Keyword.get(opts, :actor)
status = Keyword.get(opts, :status, :submitted)
query =
if status == :all do
JoinRequest
|> Ash.Query.sort(inserted_at: :desc)
else
JoinRequest
|> Ash.Query.filter(expr(status == ^status))
|> Ash.Query.sort(inserted_at: :desc)
end
Ash.read(query, actor: actor, domain: __MODULE__)
end
@doc """
Lists join requests with status `:approved` or `:rejected` (history), sorted by most recent first.
Loads `:reviewed_by_user` for displaying the reviewer. Same authorization as `list_join_requests/1`.
## Options
- `:actor` - Required. The actor for authorization (normal_user or admin).
## Returns
- `{:ok, list}` - List of JoinRequests (approved/rejected only)
- `{:error, error}` - Authorization or query error
"""
@spec list_join_requests_history(keyword()) :: {:ok, [JoinRequest.t()]} | {:error, term()}
def list_join_requests_history(opts \\ []) do
actor = Keyword.get(opts, :actor)
query =
JoinRequest
|> Ash.Query.filter(expr(status in [:approved, :rejected]))
|> Ash.Query.sort(updated_at: :desc)
|> Ash.Query.load(:reviewed_by_user)
Ash.read(query, actor: actor, domain: __MODULE__)
end
@doc """
Returns the count of join requests with status `:submitted` (unprocessed).
Used e.g. for sidebar indicator. Same authorization as `list_join_requests/1`.
## Options
- `:actor` - Required. The actor for authorization (normal_user or admin).
## Returns
- Non-negative integer (0 on error or when unauthorized).
"""
@spec count_submitted_join_requests(keyword()) :: non_neg_integer()
def count_submitted_join_requests(opts \\ []) do
actor = Keyword.get(opts, :actor)
query = JoinRequest |> Ash.Query.filter(expr(status == :submitted))
case Ash.count(query, actor: actor, domain: __MODULE__) do
{:ok, count} when is_integer(count) and count >= 0 -> count
_ -> 0
end
end
@doc """
Gets a single JoinRequest by id.
## Options
- `:actor` - Required. The actor for authorization.
## Returns
- `{:ok, request}` - The JoinRequest
- `{:ok, nil}` - Not found
- `{:error, error}` - Authorization or query error
"""
@spec get_join_request(String.t(), keyword()) :: {:ok, JoinRequest.t() | nil} | {:error, term()}
def get_join_request(id, opts \\ []) do
actor = Keyword.get(opts, :actor)
Ash.get(JoinRequest, id, actor: actor, load: [:reviewed_by_user], domain: __MODULE__)
end
@doc """
Approves a join request and promotes it to a Member.
Finds the JoinRequest by id, calls the :approve action (which sets status to
:approved and records the reviewer), then creates a Member from the typed fields
and form_data. Idempotency: if the request is already approved, returns an error.
## Options
- `:actor` - Required. The reviewer (normal_user or admin).
## Returns
- `{:ok, approved_request}` - Approved JoinRequest
- `{:error, error}` - Status error, authorization error, or Member creation error
"""
@spec approve_join_request(String.t(), keyword()) :: {:ok, JoinRequest.t()} | {:error, term()}
def approve_join_request(id, opts \\ []) do
actor = Keyword.get(opts, :actor)
with {:ok, request} <- Ash.get(JoinRequest, id, actor: actor, domain: __MODULE__),
{:ok, approved} <-
request
|> Ash.Changeset.for_update(:approve, %{}, actor: actor, domain: __MODULE__)
|> Ash.update(actor: actor, domain: __MODULE__),
{:ok, _member} <- promote_to_member(approved, actor) do
{:ok, approved}
end
end
@doc """
Rejects a join request.
Finds the JoinRequest by id and calls the :reject action (status :rejected,
records reviewer). No Member is created. Returns error if not in :submitted status.
## Options
- `:actor` - Required. The reviewer (normal_user or admin).
## Returns
- `{:ok, rejected_request}` - Rejected JoinRequest
- `{:error, error}` - Status error or authorization error
"""
@spec reject_join_request(String.t(), keyword()) :: {:ok, JoinRequest.t()} | {:error, term()}
def reject_join_request(id, opts \\ []) do
actor = Keyword.get(opts, :actor)
with {:ok, request} <- Ash.get(JoinRequest, id, actor: actor, domain: __MODULE__) do
request
|> Ash.Changeset.for_update(:reject, %{}, actor: actor, domain: __MODULE__)
|> Ash.update(actor: actor, domain: __MODULE__)
end
end
# Builds Member attrs + custom_field_values from a JoinRequest and creates the Member.
@uuid_pattern ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
@member_field_strings Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
defp promote_to_member(%JoinRequest{} = request, actor) do
{member_attrs, custom_field_values} = build_member_attrs(request)
attrs =
if Enum.empty?(custom_field_values) do
member_attrs
else
Map.put(member_attrs, :custom_field_values, custom_field_values)
end
Ash.create(Mv.Membership.Member, attrs,
action: :create_member,
actor: actor,
domain: __MODULE__
)
end
defp build_member_attrs(%JoinRequest{} = request) do
# join_date defaults to today so membership fee cycles can be generated.
base_attrs = %{
email: request.email,
first_name: request.first_name,
last_name: request.last_name,
join_date: Date.utc_today()
}
form_data = request.form_data || %{}
Enum.reduce(form_data, {base_attrs, []}, fn {key, value}, {attrs, cfvs} ->
cond do
key in @member_field_strings ->
atom_key = String.to_existing_atom(key)
{Map.put(attrs, atom_key, value), cfvs}
Regex.match?(@uuid_pattern, key) ->
cfv = %{custom_field_id: key, value: to_string(value)}
{attrs, [cfv | cfvs]}
true ->
{attrs, cfvs}
end
end)
end
end

View file

@ -0,0 +1,32 @@
defmodule Mv.Authorization.Checks.HasJoinRequestAccess do
@moduledoc """
Simple policy check: true when the actor's role has JoinRequest read/update permission.
Used for bypass policies on JoinRequest read actions. Uses SimpleCheck (not a filter-based
check) so Ash does NOT call auto_filter, which would silently return an empty list for
unauthorized actors instead of Forbidden.
Returns true for permission sets that grant JoinRequest read :all (normal_user, admin).
Returns false for all others (own_data, read_only, nil actor).
"""
use Ash.Policy.SimpleCheck
alias Mv.Authorization.Actor
alias Mv.Authorization.PermissionSets
@impl true
def describe(_opts), do: "actor has JoinRequest read/update access (normal_user or admin)"
@impl true
def match?(actor, _context, _opts) do
with ps_name when not is_nil(ps_name) <- Actor.permission_set_name(actor),
{:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
permissions <- PermissionSets.get_permissions(ps_atom) do
Enum.any?(permissions.resources, fn p ->
p.resource == "JoinRequest" and p.action == :read and p.granted
end)
else
_ -> false
end
end
end

View file

@ -218,7 +218,11 @@ defmodule Mv.Authorization.PermissionSets do
perm("MembershipFeeCycle", :update, :all),
perm("MembershipFeeCycle", :destroy, :all)
] ++
role_read_all(),
role_read_all() ++
[
perm("JoinRequest", :read, :all),
perm("JoinRequest", :update, :all)
],
pages: [
"/",
# Own profile (sidebar links to /users/:id; redirect target must be allowed)
@ -247,7 +251,10 @@ defmodule Mv.Authorization.PermissionSets do
# Edit group
"/groups/:slug/edit",
# Statistics
"/statistics"
"/statistics",
# Approval UI (Step 2)
"/join_requests",
"/join_requests/:id"
]
}
end
@ -270,7 +277,8 @@ defmodule Mv.Authorization.PermissionSets do
perm_all("Group") ++
member_group_perms ++
perm_all("MembershipFeeType") ++
perm_all("MembershipFeeCycle"),
perm_all("MembershipFeeCycle") ++
perm_all("JoinRequest"),
pages: [
# Explicit admin-only pages (for clarity and future restrictions)
"/settings",

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"
>
<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

View file

@ -59,6 +59,8 @@ msgid "Edit Member"
msgstr "Mitglied bearbeiten"
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
@ -542,6 +544,8 @@ msgstr "Benutzer*innen"
msgid "Click to sort"
msgstr "Klicke, um zu sortieren"
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "First name"
@ -745,6 +749,7 @@ msgstr "Adresse"
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
@ -895,6 +900,8 @@ msgstr "Nur möglich, wenn diesem Typ keine Mitglieder zugewiesen sind."
msgid "Quarterly"
msgstr "Vierteljährlich"
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Status"
@ -926,6 +933,8 @@ msgstr "Unbezahlt"
msgid "Yearly"
msgstr "Jährlich"
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Last name"
@ -3181,6 +3190,8 @@ msgstr "Keine Gruppenzuordnung"
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
@ -3449,3 +3460,170 @@ msgstr "Deine Angaben werden nur zur Bearbeitung deines Mitgliedsantrags und zur
#, elixir-autogen, elixir-format
msgid "Website"
msgstr "Webseite"
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Additional form data"
msgstr "Weitere Formulardaten"
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Approve"
msgstr "Genehmigen"
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Approve this join request and create a member?"
msgstr "Diesen Mitgliedsantrag genehmigen und Mitglied anlegen?"
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Approved"
msgstr "Genehmigt"
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Approved at"
msgstr "Genehmigt am"
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Back to join requests"
msgstr "Zurück zu den Mitgliedsanträgen"
#: lib/mv_web/live/join_request_live/index.ex
#, elixir-autogen, elixir-format
msgid "Click for details"
msgstr "Klicken für Details"
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Failed to approve join request."
msgstr "Mitgliedsantrag konnte nicht genehmigt werden."
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Failed to load join request."
msgstr "Mitgliedsantrag konnte nicht geladen werden."
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Failed to reject join request."
msgstr "Mitgliedsantrag konnte nicht abgelehnt werden."
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Join request"
msgstr "Mitgliedsantrag"
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Join request approved. Member created."
msgstr "Mitgliedsantrag genehmigt. Mitglied wurde angelegt."
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Join request not found."
msgstr "Mitgliedsantrag nicht gefunden."
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Join request rejected."
msgstr "Mitgliedsantrag abgelehnt."
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Join request %{email}"
msgstr "Mitgliedsantrag %{email}"
#: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/join_request_live/index.ex
#, elixir-autogen, elixir-format
msgid "Join requests"
msgstr "Mitgliedsanträge"
#: lib/mv_web/live/join_request_live/index.ex
#, elixir-autogen, elixir-format
msgid "No submitted join requests"
msgstr "Keine eingereichten Mitgliedsanträge"
#: lib/mv_web/live/join_request_live/index.ex
#, elixir-autogen, elixir-format
msgid "Not submitted yet"
msgstr "Noch nicht eingereicht"
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Pending confirmation"
msgstr "Bestätigung ausstehend"
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Reject"
msgstr "Ablehnen"
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Reject this join request?"
msgstr "Diesen Mitgliedsantrag ablehnen?"
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Rejected"
msgstr "Abgelehnt"
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Rejected at"
msgstr "Abgelehnt am"
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Request data"
msgstr "Antragsdaten"
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Review information"
msgstr "Bearbeitungsinformationen"
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Submitted"
msgstr "Eingereicht"
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Submitted at"
msgstr "Eingereicht am"
#: lib/mv_web/live/join_request_live/index.ex
#, elixir-autogen, elixir-format
msgid "No approved or rejected requests yet"
msgstr "Noch keine genehmigten oder abgelehnten Anträge"
#: lib/mv_web/live/join_request_live/index.ex
#, elixir-autogen, elixir-format
msgid "Reviewed at"
msgstr "Geprüft am"
#: lib/mv_web/live/join_request_live/index.ex
#, elixir-autogen, elixir-format
msgid "History"
msgstr "Historie"
#: lib/mv_web/live/join_request_live/index.ex
#, elixir-autogen, elixir-format
msgid "Open requests"
msgstr "Offene Anträge"
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Review by"
msgstr "Geprüft von"

View file

@ -60,6 +60,8 @@ msgid "Edit Member"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
@ -543,6 +545,8 @@ msgstr ""
msgid "Click to sort"
msgstr ""
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "First name"
@ -746,6 +750,7 @@ msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
@ -896,6 +901,8 @@ msgstr ""
msgid "Quarterly"
msgstr ""
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Status"
@ -927,6 +934,8 @@ msgstr ""
msgid "Yearly"
msgstr ""
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Last name"
@ -3181,6 +3190,8 @@ msgstr ""
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
@ -3449,3 +3460,170 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "Website"
msgstr ""
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Additional form data"
msgstr ""
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Approve"
msgstr ""
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Approve this join request and create a member?"
msgstr ""
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Approved"
msgstr ""
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Approved at"
msgstr ""
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Back to join requests"
msgstr ""
#: lib/mv_web/live/join_request_live/index.ex
#, elixir-autogen, elixir-format
msgid "Click for details"
msgstr ""
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Failed to approve join request."
msgstr ""
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Failed to load join request."
msgstr ""
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Failed to reject join request."
msgstr ""
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Join request"
msgstr ""
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Join request approved. Member created."
msgstr ""
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Join request not found."
msgstr ""
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Join request rejected."
msgstr ""
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Join request %{email}"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/join_request_live/index.ex
#, elixir-autogen, elixir-format
msgid "Join requests"
msgstr ""
#: lib/mv_web/live/join_request_live/index.ex
#, elixir-autogen, elixir-format
msgid "No submitted join requests"
msgstr ""
#: lib/mv_web/live/join_request_live/index.ex
#, elixir-autogen, elixir-format
msgid "Not submitted yet"
msgstr ""
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Pending confirmation"
msgstr ""
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Reject"
msgstr ""
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Reject this join request?"
msgstr ""
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Rejected"
msgstr ""
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Rejected at"
msgstr ""
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Request data"
msgstr ""
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Review information"
msgstr ""
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Submitted"
msgstr ""
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Submitted at"
msgstr ""
#: lib/mv_web/live/join_request_live/index.ex
#, elixir-autogen, elixir-format
msgid "No approved or rejected requests yet"
msgstr ""
#: lib/mv_web/live/join_request_live/index.ex
#, elixir-autogen, elixir-format
msgid "Reviewed at"
msgstr ""
#: lib/mv_web/live/join_request_live/index.ex
#, elixir-autogen, elixir-format
msgid "History"
msgstr ""
#: lib/mv_web/live/join_request_live/index.ex
#, elixir-autogen, elixir-format
msgid "Open requests"
msgstr ""
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Review by"
msgstr ""

View file

@ -60,6 +60,8 @@ msgid "Edit Member"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
@ -543,6 +545,8 @@ msgstr ""
msgid "Click to sort"
msgstr ""
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy
msgid "First name"
@ -746,6 +750,7 @@ msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
@ -896,6 +901,8 @@ msgstr ""
msgid "Quarterly"
msgstr ""
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Status"
@ -927,6 +934,8 @@ msgstr ""
msgid "Yearly"
msgstr ""
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy
msgid "Last name"
@ -3181,6 +3190,8 @@ msgstr ""
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
@ -3449,3 +3460,170 @@ msgstr "Your details are only used to process your membership application and to
#, elixir-autogen, elixir-format
msgid "Website"
msgstr ""
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Additional form data"
msgstr "Additional form data"
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Approve"
msgstr "Approve"
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Approve this join request and create a member?"
msgstr "Approve this membership application and create a member?"
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Approved"
msgstr "Approved"
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Approved at"
msgstr "Approved at"
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Back to join requests"
msgstr "Back to membership applications"
#: lib/mv_web/live/join_request_live/index.ex
#, elixir-autogen, elixir-format
msgid "Click for details"
msgstr "Click for details"
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Failed to approve join request."
msgstr "Failed to approve membership application."
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Failed to load join request."
msgstr "Failed to load membership application."
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Failed to reject join request."
msgstr "Failed to reject membership application."
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Join request"
msgstr "Membership application"
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Join request approved. Member created."
msgstr "Membership application approved. Member created."
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Join request not found."
msgstr "Membership application not found."
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Join request rejected."
msgstr "Membership application rejected."
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Join request %{email}"
msgstr "Membership application %{email}"
#: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/join_request_live/index.ex
#, elixir-autogen, elixir-format
msgid "Join requests"
msgstr "Membership applications"
#: lib/mv_web/live/join_request_live/index.ex
#, elixir-autogen, elixir-format
msgid "No submitted join requests"
msgstr "No submitted membership applications"
#: lib/mv_web/live/join_request_live/index.ex
#, elixir-autogen, elixir-format
msgid "Not submitted yet"
msgstr "Not submitted yet"
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Pending confirmation"
msgstr "Pending confirmation"
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Reject"
msgstr "Reject"
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Reject this join request?"
msgstr "Reject this membership application?"
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Rejected"
msgstr "Rejected"
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Rejected at"
msgstr "Rejected at"
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Request data"
msgstr "Request data"
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Review information"
msgstr "Review information"
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Submitted"
msgstr "Submitted"
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Submitted at"
msgstr "Submitted at"
#: lib/mv_web/live/join_request_live/index.ex
#, elixir-autogen, elixir-format
msgid "No approved or rejected requests yet"
msgstr "No approved or rejected requests yet"
#: lib/mv_web/live/join_request_live/index.ex
#, elixir-autogen, elixir-format
msgid "Reviewed at"
msgstr "Review date"
#: lib/mv_web/live/join_request_live/index.ex
#, elixir-autogen, elixir-format
msgid "History"
msgstr ""
#: lib/mv_web/live/join_request_live/index.ex
#, elixir-autogen, elixir-format
msgid "Open requests"
msgstr ""
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Review by"
msgstr "Review by"

View file

@ -481,8 +481,50 @@ for {email, values} <- custom_value_assignments do
end
end
# Join form: enable so membership application list is visible in dev
case Membership.get_settings() do
{:ok, settings} ->
unless settings.join_form_enabled do
Membership.update_settings(settings, %{
join_form_enabled: true,
join_form_field_ids: settings.join_form_field_ids || ["email", "first_name", "last_name", "city"],
join_form_field_required: settings.join_form_field_required || %{
"email" => true,
"first_name" => false,
"last_name" => false,
"city" => false
}
})
end
_ ->
:ok
end
# Membership applications (join requests) for UI testing: 4 submitted, 1 with extra form_data
join_request_configs = [
%{email: "antrag1@example.de", first_name: "Sandra", last_name: "Meier", form_data: %{"city" => "Berlin"}},
%{email: "antrag2@example.de", first_name: "Thomas", last_name: "Bauer", form_data: %{}},
%{email: "antrag3@example.de", first_name: "Julia", last_name: "Krause", form_data: %{"city" => "Hamburg", "notes" => "Interesse an Jugendgruppe"}},
%{email: "antrag4@example.de", first_name: "Michael", last_name: "Schmitt", form_data: %{"city" => "München"}}
]
for config <- join_request_configs do
attrs = %{
email: config.email,
first_name: config.first_name,
last_name: config.last_name,
form_data: config.form_data || %{},
schema_version: 1
}
Mv.Membership.JoinRequest
|> Ash.Changeset.for_create(:create_submitted, attrs)
|> Ash.create!(authorize?: false, domain: Mv.Membership)
end
IO.puts("✅ Dev seeds completed.")
IO.puts(" - Members: 20 with country (mostly Deutschland, 1 Österreich, 1 Schweiz)")
IO.puts(" - Test users: 4 linked to mitglied14 with roles Mitglied, Vorstand, Kassenwart, Buchhaltung")
IO.puts(" - Groups: Vorstand, Jugend, Newsletter (with assignments)")
IO.puts(" - Custom field values: ~80% filled (16 members, 46 fields each)")
IO.puts(" - Join form enabled; 4 membership applications (join requests) for UI testing")

View file

@ -38,6 +38,7 @@ defmodule Mv.Membership.JoinRequestApprovalDomainTest do
assert member_count() == count_before + 1
request_email = request.email
[member] =
Member
|> Ash.Query.filter(expr(^ref(:email) == ^request_email))
@ -56,6 +57,7 @@ defmodule Mv.Membership.JoinRequestApprovalDomainTest do
# No User should exist with this email from the approval flow
request_email = request.email
users_with_email =
Mv.Accounts.User
|> Ash.Query.filter(expr(^ref(:email) == ^request_email))
@ -99,10 +101,12 @@ defmodule Mv.Membership.JoinRequestApprovalDomainTest do
test "approve when status is pending_confirmation returns error" do
token = "pending-token-#{System.unique_integer([:positive])}"
attrs = %{
email: "pending#{System.unique_integer([:positive])}@example.com",
confirmation_token: token
}
{:ok, request} = Membership.submit_join_request(attrs, actor: nil)
assert request.status == :pending_confirmation
@ -120,6 +124,36 @@ defmodule Mv.Membership.JoinRequestApprovalDomainTest do
end
describe "approve_join_request/2 defaults" do
setup do
# Create a fee type and set it as the default in settings so SetDefaultMembershipFeeType
# can assign it when a member is created from a join request (no fee type in form_data).
actor = SystemActor.get_system_actor()
{:ok, fee_type} =
Ash.create(
Mv.MembershipFees.MembershipFeeType,
%{
name: "Default Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
interval: :yearly
},
actor: actor,
domain: Mv.MembershipFees
)
{:ok, settings} = Membership.get_settings()
settings
|> Ash.Changeset.for_update(
:update_membership_fee_settings,
%{default_membership_fee_type_id: fee_type.id},
actor: actor
)
|> Ash.update!(actor: actor)
:ok
end
test "created member has join_date and membership_fee_type when not in form_data" do
request = Fixtures.submitted_join_request_fixture()
user = Fixtures.user_with_role_fixture("normal_user")
@ -127,6 +161,7 @@ defmodule Mv.Membership.JoinRequestApprovalDomainTest do
assert {:ok, _} = Membership.approve_join_request(request.id, actor: user)
request_email = request.email
[member] =
Member
|> Ash.Query.filter(expr(^ref(:email) == ^request_email))

View file

@ -59,12 +59,14 @@ defmodule Mv.Membership.JoinRequestApprovalPolicyTest do
test "read_only cannot approve", %{request: request} do
user = Fixtures.user_with_role_fixture("read_only")
assert {:error, %Ash.Error.Forbidden{}} =
Membership.approve_join_request(request.id, actor: user)
end
test "own_data cannot approve", %{request: request} do
user = Fixtures.user_with_role_fixture("own_data")
assert {:error, %Ash.Error.Forbidden{}} =
Membership.approve_join_request(request.id, actor: user)
end
@ -97,12 +99,14 @@ defmodule Mv.Membership.JoinRequestApprovalPolicyTest do
test "read_only cannot reject", %{request: request} do
user = Fixtures.user_with_role_fixture("read_only")
assert {:error, %Ash.Error.Forbidden{}} =
Membership.reject_join_request(request.id, actor: user)
end
test "own_data cannot reject", %{request: request} do
user = Fixtures.user_with_role_fixture("own_data")
assert {:error, %Ash.Error.Forbidden{}} =
Membership.reject_join_request(request.id, actor: user)
end

View file

@ -813,13 +813,21 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do
end
end
# normal_user (Kassenwart): allowed /, /members, /members/new, /members/:id, /members/:id/edit, /groups, /groups/:slug, /join_requests
# normal_user (Kassenwart): allowed /, /members, /members/new, /members/:id, /members/:id/edit,
# /groups, /groups/:slug, /join_requests (only when join form is enabled in settings)
describe "integration: normal_user (Kassenwart) allowed paths via full router" do
setup %{conn: conn, current_user: current_user} do
member = Mv.Fixtures.member_fixture()
group = Mv.Fixtures.group_fixture()
join_request = Fixtures.submitted_join_request_fixture()
# Enable join form so /join_requests and /join_requests/:id return 200 (not redirect)
{:ok, settings} = Mv.Membership.get_settings()
if settings do
Mv.Membership.update_settings(settings, %{join_form_enabled: true})
end
{:ok,
conn: conn,
current_user: current_user,

View file

@ -323,10 +323,12 @@ defmodule Mv.Fixtures do
"""
def submitted_join_request_fixture(attrs \\ %{}) do
token = "fixture-token-#{System.unique_integer([:positive])}"
base = %{
email: "join#{System.unique_integer([:positive])}@example.com",
confirmation_token: token
}
attrs = base |> Map.merge(attrs) |> Map.put(:confirmation_token, token)
{:ok, _} = Membership.submit_join_request(attrs, actor: nil)