feat: add approval ui for join requests
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
parent
50433e607f
commit
86d9242d83
22 changed files with 1624 additions and 12 deletions
|
|
@ -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 """
|
||||
|
|
|
|||
37
lib/membership/join_request/changes/approve_request.ex
Normal file
37
lib/membership/join_request/changes/approve_request.ex
Normal 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
|
||||
36
lib/membership/join_request/changes/reject_request.ex
Normal file
36
lib/membership/join_request/changes/reject_request.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
32
lib/mv/authorization/checks/has_join_request_access.ex
Normal file
32
lib/mv/authorization/checks/has_join_request_access.ex
Normal 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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
200
lib/mv_web/live/join_request_live/index.ex
Normal file
200
lib/mv_web/live/join_request_live/index.ex
Normal 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
|
||||
320
lib/mv_web/live/join_request_live/show.ex
Normal file
320
lib/mv_web/live/join_request_live/show.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue