diff --git a/lib/membership/join_request.ex b/lib/membership/join_request.ex
index cf220a0..05a9e8d 100644
--- a/lib/membership/join_request.ex
+++ b/lib/membership/join_request.ex
@@ -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 """
diff --git a/lib/membership/join_request/changes/approve_request.ex b/lib/membership/join_request/changes/approve_request.ex
new file mode 100644
index 0000000..aee6874
--- /dev/null
+++ b/lib/membership/join_request/changes/approve_request.ex
@@ -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
diff --git a/lib/membership/join_request/changes/reject_request.ex b/lib/membership/join_request/changes/reject_request.ex
new file mode 100644
index 0000000..939df95
--- /dev/null
+++ b/lib/membership/join_request/changes/reject_request.ex
@@ -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
diff --git a/lib/membership/join_request/changes/set_submitted_for_seeding.ex b/lib/membership/join_request/changes/set_submitted_for_seeding.ex
new file mode 100644
index 0000000..c53b6d1
--- /dev/null
+++ b/lib/membership/join_request/changes/set_submitted_for_seeding.ex
@@ -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
diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex
index 3f34903..c04686b 100644
--- a/lib/membership/membership.ex
+++ b/lib/membership/membership.ex
@@ -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
diff --git a/lib/mv/authorization/checks/has_join_request_access.ex b/lib/mv/authorization/checks/has_join_request_access.ex
new file mode 100644
index 0000000..65256c9
--- /dev/null
+++ b/lib/mv/authorization/checks/has_join_request_access.ex
@@ -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
diff --git a/lib/mv/authorization/permission_sets.ex b/lib/mv/authorization/permission_sets.ex
index fffc818..3ffae93 100644
--- a/lib/mv/authorization/permission_sets.ex
+++ b/lib/mv/authorization/permission_sets.ex
@@ -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",
diff --git a/lib/mv_web/components/layouts.ex b/lib/mv_web/components/layouts.ex
index 17fca11..25dfb1d 100644
--- a/lib/mv_web/components/layouts.ex
+++ b/lib/mv_web/components/layouts.ex
@@ -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
- <.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}
+ />
<% 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.
diff --git a/lib/mv_web/components/layouts/sidebar.ex b/lib/mv_web/components/layouts/sidebar.ex
index cb94fb3..49d9cae 100644
--- a/lib/mv_web/components/layouts/sidebar.ex
+++ b/lib/mv_web/components/layouts/sidebar.ex
@@ -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"""
@@ -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" />
+
+ <.icon name={@icon} class="size-5 shrink-0" aria-hidden="true" />
+ <%= if @indicator_dot do %>
+
+
+ <% end %>
+
diff --git a/lib/mv_web/helpers/date_formatter.ex b/lib/mv_web/helpers/date_formatter.ex
index eaa9271..8674e21 100644
--- a/lib/mv_web/helpers/date_formatter.ex
+++ b/lib/mv_web/helpers/date_formatter.ex
@@ -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
diff --git a/lib/mv_web/live/join_request_live/index.ex b/lib/mv_web/live/join_request_live/index.ex
new file mode 100644
index 0000000..87797e9
--- /dev/null
+++ b/lib/mv_web/live/join_request_live/index.ex
@@ -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"""
+
+ <.header>
+ {gettext("Join requests")}
+
+
+
+
+
{gettext("Open requests")}
+ <%= if Enum.empty?(@join_requests) do %>
+
+
{gettext("No submitted join requests")}
+
+ <% 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 :let={req} label={gettext("First name")}>
+ <.maybe_value value={req.first_name} empty_sr_text={gettext("Not specified")}>
+ {req.first_name}
+
+
+ <:col :let={req} label={gettext("Last name")}>
+ <.maybe_value value={req.last_name} empty_sr_text={gettext("Not specified")}>
+ {req.last_name}
+
+
+ <:col :let={req} label={gettext("Email")}>
+ {req.email}
+
+ <:col :let={req} label={gettext("Status")}>
+ <.badge variant={status_badge_variant(req.status)}>
+ {format_status(req.status)}
+
+
+
+ <% end %>
+
+
+
+
{gettext("History")}
+ <%= if Enum.empty?(@join_requests_history) do %>
+
+
+ {gettext("No approved or rejected requests yet")}
+
+
+ <% 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 :let={req} label={gettext("First name")}>
+ <.maybe_value value={req.first_name} empty_sr_text={gettext("Not specified")}>
+ {req.first_name}
+
+
+ <:col :let={req} label={gettext("Last name")}>
+ <.maybe_value value={req.last_name} empty_sr_text={gettext("Not specified")}>
+ {req.last_name}
+
+
+ <:col :let={req} label={gettext("Status")}>
+ <.badge variant={status_badge_variant(req.status)}>
+ {format_status(req.status)}
+
+
+ <:col :let={req} label={gettext("Reviewed at")}>
+ {review_date(req)}
+
+ <:col :let={req} label={gettext("Review by")}>
+ {reviewer_display(req)}
+
+
+ <% end %>
+
+
+
+ """
+ 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
diff --git a/lib/mv_web/live/join_request_live/show.ex b/lib/mv_web/live/join_request_live/show.ex
new file mode 100644
index 0000000..72579b3
--- /dev/null
+++ b/lib/mv_web/live/join_request_live/show.ex
@@ -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"""
+
+ <.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")}
+
+
+ {gettext("Join request")}
+
+
+ <%= if @join_request do %>
+
+
+
{gettext("Request data")}
+
+ <.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)}
+ />
+
+ {gettext("Status")}:
+
+ <.badge variant={status_badge_variant(@join_request.status)}>
+ {format_status(@join_request.status)}
+
+
+
+
+
+
+ <%= if map_size(@join_request.form_data || %{}) > 0 do %>
+
+
{gettext("Additional form data")}
+
+ <%= for {key, value} <- format_form_data(@join_request.form_data, @join_form_field_ids || []) do %>
+ <.field_row label={key} value={to_string(value)} />
+ <% end %>
+
+
+ <% end %>
+
+ <%= if @join_request.status in [:approved, :rejected] do %>
+
+
{gettext("Review information")}
+
+ <%= 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="-"
+ />
+
+
+ <% end %>
+
+ <%= if @join_request.status == :submitted do %>
+
+ <.button
+ variant="danger"
+ phx-click="reject"
+ data-confirm={gettext("Reject this join request?")}
+ data-testid="join-request-reject-btn"
+ >
+ {gettext("Reject")}
+
+ <.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")}
+
+
+ <% end %>
+
+ <% end %>
+
+ """
+ end
+
+ attr :label, :string, required: true
+ attr :value, :any, default: nil
+ attr :empty_text, :string, default: nil
+
+ defp field_row(assigns) do
+ ~H"""
+
+ {@label}:
+
+ <%= if @value && @value != "" do %>
+ {@value}
+ <% else %>
+
+ {@empty_text || gettext("Not specified")}
+
+ <% end %>
+
+
+ """
+ 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
diff --git a/lib/mv_web/page_paths.ex b/lib/mv_web/page_paths.ex
index 551cada..70e0ddb 100644
--- a/lib/mv_web/page_paths.ex
+++ b/lib/mv_web/page_paths.ex
@@ -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
diff --git a/lib/mv_web/router.ex b/lib/mv_web/router.ex
index 74fcd22..945e22c 100644
--- a/lib/mv_web/router.ex
+++ b/lib/mv_web/router.ex
@@ -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
diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po
index 96b8c07..71c42be 100644
--- a/priv/gettext/de/LC_MESSAGES/default.po
+++ b/priv/gettext/de/LC_MESSAGES/default.po
@@ -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"
diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot
index 65197e1..b04f216 100644
--- a/priv/gettext/default.pot
+++ b/priv/gettext/default.pot
@@ -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 ""
diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po
index 4ebce69..0269a31 100644
--- a/priv/gettext/en/LC_MESSAGES/default.po
+++ b/priv/gettext/en/LC_MESSAGES/default.po
@@ -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"
diff --git a/priv/repo/seeds_dev.exs b/priv/repo/seeds_dev.exs
index 352299f..5f30a08 100644
--- a/priv/repo/seeds_dev.exs
+++ b/priv/repo/seeds_dev.exs
@@ -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 mitglied1–4 with roles Mitglied, Vorstand, Kassenwart, Buchhaltung")
IO.puts(" - Groups: Vorstand, Jugend, Newsletter (with assignments)")
IO.puts(" - Custom field values: ~80% filled (16 members, 4–6 fields each)")
+IO.puts(" - Join form enabled; 4 membership applications (join requests) for UI testing")
diff --git a/test/membership/join_request_approval_domain_test.exs b/test/membership/join_request_approval_domain_test.exs
index 9578fea..1f9b3c2 100644
--- a/test/membership/join_request_approval_domain_test.exs
+++ b/test/membership/join_request_approval_domain_test.exs
@@ -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))
diff --git a/test/membership/join_request_approval_policy_test.exs b/test/membership/join_request_approval_policy_test.exs
index e658435..6c09526 100644
--- a/test/membership/join_request_approval_policy_test.exs
+++ b/test/membership/join_request_approval_policy_test.exs
@@ -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
diff --git a/test/mv_web/plugs/check_page_permission_test.exs b/test/mv_web/plugs/check_page_permission_test.exs
index 31922b0..d8c46e1 100644
--- a/test/mv_web/plugs/check_page_permission_test.exs
+++ b/test/mv_web/plugs/check_page_permission_test.exs
@@ -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,
diff --git a/test/support/fixtures.ex b/test/support/fixtures.ex
index 56347c9..73bf12a 100644
--- a/test/support/fixtures.ex
+++ b/test/support/fixtures.ex
@@ -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)