Merge pull request 'add approval ui for join requests' (#468) from feature/308-web-form into main
All checks were successful
continuous-integration/drone/push Build is passing

Reviewed-on: #468
This commit is contained in:
simon 2026-03-11 02:29:54 +01:00
commit cb69521cda
27 changed files with 2104 additions and 23 deletions

View file

@ -86,7 +86,7 @@ lib/
│ ├── membership.ex # Domain definition
│ ├── member.ex # Member resource
│ ├── join_request.ex # JoinRequest (public join form, double opt-in)
│ ├── join_request/ # JoinRequest changes (SetConfirmationToken, FilterFormDataByAllowlist, ConfirmRequest)
│ ├── join_request/ # JoinRequest changes (Helpers, SetConfirmationToken, FilterFormDataByAllowlist, ConfirmRequest, ApproveRequest, RejectRequest)
│ ├── custom_field.ex # Custom field (definition) resource
│ ├── custom_field_value.ex # Custom field value resource
│ ├── setting.ex # Global settings (singleton resource; incl. join form config)

View file

@ -1,6 +1,6 @@
# Onboarding & Join High-Level Concept
**Status:** Draft for design decisions and implementation specs
**Status:** Draft for design decisions and implementation specs. **Prio 1 (Subtasks 14) implemented.**
**Scope:** Prio 1 = public Join form; Step 2 = Vorstand approval. Invite-Link and OIDC JIT are out of scope and documented only as future entry paths.
**Related:** Issue #308, roles-and-permissions-architecture, page-permission-route-coverage.
@ -102,10 +102,57 @@
## 3. Step 2: Vorstand Approval
- **Goal:** Board (Vorstand) can review join requests (e.g. list status "submitted") and approve or reject.
- **Route:** **`/join_requests`** for the approval UI (list and detail). See §3.1 for full specification.
- **Outcome of approval (admin-configurable):**
- **Default:** Approval creates **Member only**; no User is created. An admin can link a User later if needed.
- **Optional (configurable):** If an option is set, approval may also create a **User** (e.g. invite-to-set-password). This is **open for later**; implementation concepts will be detailed when that option is implemented.
- **Permissions:** Approval uses the existing permission set **normal_user** (e.g. role "Kassenwart"). JoinRequest gets read and update (or dedicated approve/reject actions) for scope :all in normal_user, and the approval page (e.g. `/join_requests` or `/onboarding/join_requests`) is added to normal_users allowed pages.
- **Permissions:** Approval uses the existing permission set **normal_user** (e.g. role "Kassenwart"). JoinRequest gets read and update (or dedicated approve/reject actions) for scope :all in normal_user, and **`/join_requests`** (and **`/join_requests/:id`** for detail) are added to normal_users allowed pages.
### 3.1 Step 2 Approval (detail)
Implementation spec for Subtask 5.
#### Route and pages
- **List:** **`/join_requests`** list of join requests. Filter by status (default or primary view: status `submitted`); optional view for "all" or "approved/rejected" for audit.
- **Detail:** **`/join_requests/:id`** single join request with all data (typed fields + `form_data`), actions Approve / Reject.
#### Backend (JoinRequest)
- **New actions (authenticated only):**
- **`approve`** (update): allowed only when status is `submitted`. Sets status `approved`, `approved_at`, `reviewed_by_user_id` (actor). Triggers promotion to Member (see Promotion below).
- **`reject`** (update): allowed only when status is `submitted`. Sets status `rejected`, `rejected_at`, `reviewed_by_user_id`. No reason field in MVP.
- **Policies:** `approve` and `reject` permitted via **HasPermission** for permission set **normal_user** (read/update or explicit approve/reject on JoinRequest, scope :all). Not allowed for `actor: nil`.
- **Domain:** Expose `list_join_requests/1` (e.g. filter by status, with actor), `approve_join_request/2` (id, actor), `reject_join_request/2` (id, actor). Read action for JoinRequest for normal_user scope :all so list/detail can load data.
#### Promotion: JoinRequest → Member
- **When:** On successful `approve` only (status was `submitted`).
- **Mapping:**
- JoinRequest typed fields → Member: **email**, **first_name**, **last_name** copied to Member attributes.
- **form_data** (jsonb): keys that match `Mv.Constants.member_fields()` (atom names or string keys) → corresponding Member attributes. Keys that are custom field IDs (UUID format) → create **CustomFieldValue** records linked to the new Member.
- **Defaults:** e.g. `join_date` = today if not in form_data; `membership_fee_type_id` = default from settings (or first available) if not provided. Handle required Member validations (e.g. email already present from JoinRequest).
- **Implementation:** Prefer a single Ash change (e.g. `JoinRequest.Changes.PromoteToMember`) or a domain function that builds member attributes + custom_field_values from the approved JoinRequest and calls Member `create_member` (actor: reviewer or system actor as per CODE_GUIDELINES; document choice). No User created in MVP.
- **Atomicity:** The approve flow (get JoinRequest → update to approved → promote to Member) runs inside **`Ash.transact(JoinRequest, fn -> ... end)`** so that if Member creation fails (e.g. validation, unique constraint), the JoinRequest status is rolled back and remains consistent.
- **Idempotency:** If approve is called again by mistake (e.g. race), either reject transition when status is already `approved` or ensure Member creation is idempotent (e.g. do not create duplicate Member for same JoinRequest).
#### Permission sets and routing
- **PermissionSets (normal_user):** Add JoinRequest **read** :all and **update** :all (or **approve** / **reject** if using dedicated actions). Add pages **`/join_requests`** and **`/join_requests/:id`** to the normal_user pages list.
- **Router:** Register live routes for list and detail; add entries to **page-permission-route-coverage.md** and extend plug tests so normal_user is allowed, read_only/own_data denied.
#### UI/UX (approval)
- **List:** Table or card list with columns e.g. submitted_at, first_name, last_name, email, status. Primary filter or default filter: status = `submitted`. Buttons or links to open detail. Follow existing list patterns (e.g. Members/Groups): header, back link, CoreComponents table.
- **Detail:** Show all request data (typed + form_data rendered by field). Buttons: **Approve** (primary), **Reject** (secondary). Reject in MVP: no reason field; just set status to rejected and audit fields.
- **Accessibility and i18n:** Same standards as rest of app (labels, Gettext, keyboard, ARIA where needed).
#### Tests
- JoinRequest: policy tests approve/reject allowed for normal_user (and admin), forbidden for nil/own_data/read_only.
- Domain: approve creates one Member with correct mapped data; reject only updates status and audit fields; approve when already approved is no-op or error.
- Page permission: normal_user can GET `/join_requests` and `/join_requests/:id`; read_only/own_data cannot.
- Optional: LiveView smoke test list loads, approve/reject from detail updates state.
---
@ -153,6 +200,7 @@
- **Rate limiting:** Honeypot + rate limiting from the start (e.g. Hammer.Plug).
- **Settings:** Own section "Onboarding / Join" in global settings; `join_form_enabled` plus field selection; display as list/badges; detailed UX in a **separate subtask**.
- **Approval permission:** normal_user; JoinRequest read/update and approval page added to normal_user; no new permission set.
- **Approval route:** **`/join_requests`** (list) and **`/join_requests/:id`** (detail).
- **Resend confirmation:** If not in Prio 1, create a separate ticket immediately.
**Open for later:**
@ -180,26 +228,26 @@
### Prio 1 Public Join (4 subtasks)
#### 1. JoinRequest resource and public policies
#### 1. JoinRequest resource and public policies **(done)**
- **Scope:** Ash resource `JoinRequest` per §2.3.2: status (`pending_confirmation`, `submitted`, `approved`, `rejected`), email (required), first_name, last_name (optional), form_data (jsonb), schema_version; confirmation_token_hash, confirmation_token_expires_at; submitted_at, approved_at, rejected_at, reviewed_by_user_id, source. Migration; unique_index on confirmation_token_hash (or equivalent for idempotency).
- **Policies:** Public actions **submit** (create) and **confirm** (update) allowed with `actor: nil`; no system-actor fallback, no undocumented `authorize?: false`.
- **Boundary:** No UI, no emails only resource, persistence, and actions callable with nil actor.
- **Done:** Resource and migration in place; tests for create/update with `actor: nil` and for idempotent confirm (same token twice → no second update).
#### 2. Submit and confirm flow
#### 2. Submit and confirm flow **(done)**
- **Scope:** Form submit → **create** JoinRequest (status `pending_confirmation`, token hash + expiry, form data) → send confirmation email (reuse AshAuthentication sender pattern). Route **`/confirm_join/:token`** → verify token (hash and lookup) → **update** JoinRequest to status `submitted`, set submitted_at, invalidate token (idempotent if already submitted). Optional: Oban (or similar) job to **hard-delete** JoinRequests in `pending_confirmation` with confirmation_token_expires_at older than 24h.
- **Boundary:** No join-form UI, no admin settings only backend create/update and email/route.
- **Done:** Submit creates one JoinRequest; confirm updates it to submitted; double-click idempotent; expired token shows clear message; cleanup job implemented and documented. Tests for these cases.
#### 3. Admin: Join form settings
#### 3. Admin: Join form settings **(done)**
- **Scope:** Section "Onboarding / Join" in global settings (§2.6): `join_form_enabled`, selection of join-form fields (from member_fields + custom fields), "required" per field. Persist (e.g. Setting or existing config). UI e.g. badges with remove + dropdown/modal to add (details in sub-subtask if needed).
- **Boundary:** No public form only save/load of config and **server-side allowlist** for use in subtask 4.
- **Done:** Settings save/load; allowlist available in backend for join form; tests.
#### 4. Public join page and anti-abuse
#### 4. Public join page and anti-abuse **(done)**
- **Scope:** Route **`/join`** (public). **Add `/join` to the page-permission plugs public path list** so unauthenticated access is allowed. LiveView (or controller + form). Form fields from allowlist (subtask 3); copy per §2.5. **Honeypot** and **rate limiting** (e.g. Hammer.Plug) on join/submit. After submit: show "We have saved your details … click the link …". Expired-link page: clear message + "submit form again". Public-path tests updated to include `/join`.
- **Boundary:** No approval UI, no User/Member creation only public page, form, anti-abuse, and wiring to submit/confirm flow (subtask 2).
@ -215,7 +263,8 @@
#### 5. Approval UI (Vorstand)
- **Scope:** List JoinRequests (status "submitted"), approve/reject actions; on approve create Member (no User in MVP). Permission: normal_user; add page to PermissionSets. Populate audit fields (approved_at, rejected_at, reviewed_by_user_id).
- **Route:** **`/join_requests`** (list), **`/join_requests/:id`** (detail). Full specification: §3.1.
- **Scope:** List JoinRequests (status "submitted"), approve/reject actions; on approve create Member (no User in MVP). Permission: normal_user; add JoinRequest read/update (or approve/reject) and pages `/join_requests`, `/join_requests/:id` to PermissionSets. Populate audit fields (approved_at, rejected_at, reviewed_by_user_id). Promotion: JoinRequest → Member per §3.1 (mapping, defaults, idempotency).
- **Boundary:** Separate ticket; builds on JoinRequest and existing Member creation.
---
@ -223,7 +272,7 @@
## 9. References
- `docs/roles-and-permissions-architecture.md` Permission sets, roles, page permissions.
- `docs/page-permission-route-coverage.md` Public paths, plug behaviour, tests.
- `docs/page-permission-route-coverage.md` Public paths, plug behaviour, tests; add `/join_requests` and `/join_requests/:id` for Step 2 (normal_user).
- `lib/mv_web/plugs/check_page_permission.ex` Public path list; **add `/join`** in `public_path?/1`.
- `lib/mv/accounts/user/senders/send_new_user_confirmation_email.ex` Existing confirmation-email pattern (token, link, Mailer).
- Hammer / Hammer.Plug (e.g. hexdocs.pm/hammer) Rate limiting for Phoenix/Plug.

View file

@ -31,8 +31,10 @@ This document lists all protected routes, which permission set may access them,
| `/admin/roles/new` | ✗ | ✗ | ✗ | ✓ |
| `/admin/roles/:id` | ✗ | ✗ | ✗ | ✓ |
| `/admin/roles/:id/edit` | ✗ | ✗ | ✗ | ✓ |
| `/join_requests` (Step 2) | ✗ | ✗ | ✓ | ✓ |
| `/join_requests/:id` (Step 2) | ✗ | ✗ | ✓ | ✓ |
**Note:** Permission sets define `/custom_field_values` and related paths, but there are no such routes in the router; those entries are for future use.
**Note:** Permission sets define `/custom_field_values` and related paths, but there are no such routes in the router; those entries are for future use. Step 2 (Approval UI) adds `/join_requests` and `/join_requests/:id` for normal_user and admin; routes and permission set entries are not yet implemented; tests exist in `check_page_permission_test.exs` (describe "join_requests routes" and integration blocks).
## Public Paths (no permission check)
@ -55,6 +57,7 @@ The join confirmation route `GET /confirm_join/:token` is public (matched by `/c
- Unauthenticated: nil user denied, redirect `/sign-in`.
- Public: unauthenticated allowed `/auth/sign-in`, `/register`.
- Error: no role, invalid permission_set_name → denied.
- **Join requests (Step 2):** normal_user and admin allowed `/join_requests`, `/join_requests/:id`; read_only and own_data denied. Tests fail (red) until routes and permission set are added.
### Integration tests (full router, Mitglied = own_data)

View file

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

View file

@ -0,0 +1,31 @@
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
alias Mv.Membership.JoinRequest.Changes.Helpers
@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 = Helpers.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
end

View file

@ -0,0 +1,19 @@
defmodule Mv.Membership.JoinRequest.Changes.Helpers do
@moduledoc """
Shared helpers for JoinRequest change modules (e.g. ApproveRequest, RejectRequest).
"""
@doc """
Extracts the actor's user id from the Ash change context.
Supports both atom and string keys for compatibility with different actor representations.
"""
@spec actor_id(term()) :: String.t() | nil
def actor_id(nil), do: nil
def actor_id(actor) when is_map(actor) do
Map.get(actor, :id) || Map.get(actor, "id")
end
def actor_id(_), do: nil
end

View file

@ -0,0 +1,30 @@
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
alias Mv.Membership.JoinRequest.Changes.Helpers
@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 = Helpers.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
end

View file

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

View file

@ -87,7 +87,8 @@ defmodule Mv.Membership do
end
resource Mv.Membership.JoinRequest do
# submit_join_request/2 implemented as custom function below (create + send email)
# Public submit/confirm and approval domain functions are implemented as custom
# functions below to handle cross-resource operations (Member promotion on approve).
end
end
@ -455,6 +456,20 @@ defmodule Mv.Membership do
end
end
@doc """
Returns whether the public join form is enabled in global settings.
Used by the web layer (JoinRequest LiveViews, Layouts, plugs) to decide whether
to show join-related UI and to gate access to join request pages.
"""
@spec join_form_enabled?() :: boolean()
def join_form_enabled? do
case get_settings() do
{:ok, %{join_form_enabled: true}} -> true
_ -> false
end
end
@doc """
Returns the allowlist of fields configured for the public join form.
@ -507,4 +522,225 @@ 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
{:error, error} ->
Logger.debug("count_submitted_join_requests failed: #{inspect(error)}")
0
_ ->
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],
not_found_error?: false,
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)
result =
Ash.transact(JoinRequest, fn ->
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)
# Ash.transact returns {:ok, callback_result}; flatten so callers get {:ok, request} | {:error, term()}
case result do
{:ok, inner} -> inner
{:error, _} = err -> err
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
# Evaluated at compile time so we do not resolve member_fields() on every reduce step.
@member_field_strings Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
defp promote_to_member(%JoinRequest{} = request, actor) do
{member_attrs, custom_field_values} = build_member_attrs(request)
attrs =
if Enum.empty?(custom_field_values) do
member_attrs
else
Map.put(member_attrs, :custom_field_values, custom_field_values)
end
Ash.create(Mv.Membership.Member, attrs,
action: :create_member,
actor: actor,
domain: __MODULE__
)
end
defp build_member_attrs(%JoinRequest{} = request) do
# join_date defaults to today so membership fee cycles can be generated.
base_attrs = %{
email: request.email,
first_name: request.first_name,
last_name: request.last_name,
join_date: Date.utc_today()
}
form_data = request.form_data || %{}
Enum.reduce(form_data, {base_attrs, []}, fn {key, value}, {attrs, cfvs} ->
cond do
key in @member_field_strings ->
atom_key = String.to_existing_atom(key)
{Map.put(attrs, atom_key, value), cfvs}
Regex.match?(@uuid_pattern, key) ->
cfv = %{custom_field_id: key, value: to_string(value)}
{attrs, [cfv | cfvs]}
true ->
{attrs, cfvs}
end
end)
end
end

View file

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

View file

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

View file

@ -44,7 +44,18 @@ defmodule MvWeb.Layouts do
def app(assigns) do
club_name = get_club_name()
assigns = assign(assigns, :club_name, club_name)
join_form_enabled = Mv.Membership.join_form_enabled?()
# TODO: get_join_form_enabled and unprocessed count run on every page load; consider
# loading count only on navigation or caching briefly if performance becomes an issue.
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 +89,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 +138,13 @@ defmodule MvWeb.Layouts do
end
end
defp get_unprocessed_join_requests_count(nil, _), do: 0
defp get_unprocessed_join_requests_count(_user, false), do: 0
defp get_unprocessed_join_requests_count(user, true) do
Mv.Membership.count_submitted_join_requests(actor: user)
end
@doc """
Shows the flash group with standard titles and content.

View file

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

View file

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

View file

@ -0,0 +1,47 @@
defmodule MvWeb.JoinRequestLive.Helpers do
@moduledoc """
Shared helpers for JoinRequest LiveViews (Index, Show): status display,
badge variants, and reviewer display.
"""
use Gettext, backend: MvWeb.Gettext
@doc "Human-readable label for a join request status atom."
def format_status(:pending_confirmation), do: gettext("Pending confirmation")
def format_status(:submitted), do: gettext("Submitted")
def format_status(:approved), do: gettext("Approved")
def format_status(:rejected), do: gettext("Rejected")
def format_status(other), do: to_string(other)
@doc "Badge variant for the status (used with CoreComponents.badge)."
def status_badge_variant(:submitted), do: :info
def status_badge_variant(:approved), do: :success
def status_badge_variant(:rejected), do: :error
def status_badge_variant(_), do: :neutral
@doc """
Returns the reviewer display string (e.g. email) for a join request, or nil if none.
Accepts a join request struct or map with optional :reviewed_by_user (loaded User struct).
"""
def reviewer_display(req) when is_map(req) do
user = Map.get(req, :reviewed_by_user)
case user do
nil ->
nil
%{email: email} when is_binary(email) ->
s = String.trim(email)
if s == "", do: nil, else: s
%{"email" => email} when is_binary(email) ->
s = String.trim(email)
if s == "", do: nil, else: s
_ ->
nil
end
end
def reviewer_display(_), do: nil
end

View file

@ -0,0 +1,175 @@
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
alias MvWeb.JoinRequestLive.Helpers, as: JoinRequestHelpers
@impl true
def mount(_params, _session, socket) do
actor = current_actor(socket)
cond do
not Membership.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={JoinRequestHelpers.status_badge_variant(req.status)}>
{JoinRequestHelpers.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={JoinRequestHelpers.status_badge_variant(req.status)}>
{JoinRequestHelpers.format_status(req.status)}
</.badge>
</:col>
<:col :let={req} label={gettext("Reviewed at")}>
{review_date(req)}
</:col>
<:col :let={req} label={gettext("Review by")}>
{JoinRequestHelpers.reviewer_display(req) || ""}
</:col>
</.table>
<% end %>
</div>
</div>
</Layouts.app>
"""
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 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
end

View file

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

View file

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

View file

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

View file

@ -59,6 +59,8 @@ msgid "Edit Member"
msgstr "Mitglied bearbeiten"
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
@ -542,6 +544,8 @@ msgstr "Benutzer*innen"
msgid "Click to sort"
msgstr "Klicke, um zu sortieren"
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "First name"
@ -745,6 +749,7 @@ msgstr "Adresse"
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
@ -895,6 +900,8 @@ msgstr "Nur möglich, wenn diesem Typ keine Mitglieder zugewiesen sind."
msgid "Quarterly"
msgstr "Vierteljährlich"
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Status"
@ -926,6 +933,8 @@ msgstr "Unbezahlt"
msgid "Yearly"
msgstr "Jährlich"
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Last name"
@ -3181,6 +3190,8 @@ msgstr "Keine Gruppenzuordnung"
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
@ -3449,3 +3460,166 @@ 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/helpers.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/helpers.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/helpers.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/helpers.ex
#, elixir-autogen, elixir-format
msgid "Submitted"
msgstr "Eingereicht"
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Submitted at"
msgstr "Eingereicht am"
#: lib/mv_web/live/join_request_live/index.ex
#, elixir-autogen, elixir-format
msgid "No approved or rejected requests yet"
msgstr "Noch keine genehmigten oder abgelehnten Anträge"
#: lib/mv_web/live/join_request_live/index.ex
#, elixir-autogen, elixir-format
msgid "Reviewed at"
msgstr "Geprüft am"
#: lib/mv_web/live/join_request_live/index.ex
#, elixir-autogen, elixir-format
msgid "History"
msgstr "Historie"
#: lib/mv_web/live/join_request_live/index.ex
#, elixir-autogen, elixir-format
msgid "Open requests"
msgstr "Offene Anträge"
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Review by"
msgstr "Geprüft von"

View file

@ -60,6 +60,8 @@ msgid "Edit Member"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
@ -543,6 +545,8 @@ msgstr ""
msgid "Click to sort"
msgstr ""
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "First name"
@ -746,6 +750,7 @@ msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
@ -896,6 +901,8 @@ msgstr ""
msgid "Quarterly"
msgstr ""
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Status"
@ -927,6 +934,8 @@ msgstr ""
msgid "Yearly"
msgstr ""
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Last name"
@ -3181,6 +3190,8 @@ msgstr ""
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
@ -3449,3 +3460,166 @@ 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/helpers.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/helpers.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/helpers.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/helpers.ex
#, elixir-autogen, elixir-format
msgid "Submitted"
msgstr ""
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Submitted at"
msgstr ""
#: lib/mv_web/live/join_request_live/index.ex
#, elixir-autogen, elixir-format
msgid "No approved or rejected requests yet"
msgstr ""
#: lib/mv_web/live/join_request_live/index.ex
#, elixir-autogen, elixir-format
msgid "Reviewed at"
msgstr ""
#: lib/mv_web/live/join_request_live/index.ex
#, elixir-autogen, elixir-format
msgid "History"
msgstr ""
#: lib/mv_web/live/join_request_live/index.ex
#, elixir-autogen, elixir-format
msgid "Open requests"
msgstr ""
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Review by"
msgstr ""

View file

@ -60,6 +60,8 @@ msgid "Edit Member"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
@ -543,6 +545,8 @@ msgstr ""
msgid "Click to sort"
msgstr ""
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy
msgid "First name"
@ -746,6 +750,7 @@ msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
@ -896,6 +901,8 @@ msgstr ""
msgid "Quarterly"
msgstr ""
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Status"
@ -927,6 +934,8 @@ msgstr ""
msgid "Yearly"
msgstr ""
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy
msgid "Last name"
@ -3181,6 +3190,8 @@ msgstr ""
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
@ -3449,3 +3460,166 @@ 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/helpers.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/helpers.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/helpers.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/helpers.ex
#, elixir-autogen, elixir-format
msgid "Submitted"
msgstr "Submitted"
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Submitted at"
msgstr "Submitted at"
#: lib/mv_web/live/join_request_live/index.ex
#, elixir-autogen, elixir-format
msgid "No approved or rejected requests yet"
msgstr "No approved or rejected requests yet"
#: lib/mv_web/live/join_request_live/index.ex
#, elixir-autogen, elixir-format
msgid "Reviewed at"
msgstr "Review date"
#: lib/mv_web/live/join_request_live/index.ex
#, elixir-autogen, elixir-format
msgid "History"
msgstr ""
#: lib/mv_web/live/join_request_live/index.ex
#, elixir-autogen, elixir-format
msgid "Open requests"
msgstr ""
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Review by"
msgstr "Review by"

View file

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

View file

@ -0,0 +1,174 @@
defmodule Mv.Membership.JoinRequestApprovalDomainTest do
@moduledoc """
Domain tests for JoinRequest approval: approve/reject and promotion to Member (Step 2).
Asserts that approve creates one Member with mapped data, reject does not create Member,
status rules, and idempotency. No User creation in MVP.
"""
use Mv.DataCase, async: true
import Ash.Expr
require Ash.Query
alias Mv.Fixtures
alias Mv.Helpers.SystemActor
alias Mv.Membership
alias Mv.Membership.Member
defp member_count do
actor = SystemActor.get_system_actor()
{:ok, members} = Membership.list_members(actor: actor)
length(members)
end
describe "approve_join_request/2 promotion to Member" do
test "approve creates exactly one member with email, first_name, last_name from JoinRequest" do
request =
Fixtures.submitted_join_request_fixture(%{
first_name: "Approved",
last_name: "User"
})
count_before = member_count()
user = Fixtures.user_with_role_fixture("normal_user")
assert {:ok, approved} = Membership.approve_join_request(request.id, actor: user)
assert approved.status == :approved
assert member_count() == count_before + 1
request_email = request.email
[member] =
Member
|> Ash.Query.filter(expr(^ref(:email) == ^request_email))
|> Ash.read!(actor: SystemActor.get_system_actor(), domain: Membership)
assert member.email == request.email
assert member.first_name == request.first_name
assert member.last_name == request.last_name
end
test "approve does not create a User (MVP)" do
request = Fixtures.submitted_join_request_fixture()
user = Fixtures.user_with_role_fixture("normal_user")
assert {:ok, _} = Membership.approve_join_request(request.id, actor: user)
# 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))
|> Ash.read!(authorize?: false)
assert users_with_email == []
end
end
describe "reject_join_request/2" do
test "reject does not create a member" do
request = Fixtures.submitted_join_request_fixture()
count_before = member_count()
user = Fixtures.user_with_role_fixture("normal_user")
assert {:ok, rejected} = Membership.reject_join_request(request.id, actor: user)
assert rejected.status == :rejected
assert rejected.rejected_at != nil
assert member_count() == count_before
end
end
describe "approve_join_request/2 status and idempotency" do
test "approve when status is already approved is idempotent or returns error" do
request = Fixtures.submitted_join_request_fixture()
user = Fixtures.user_with_role_fixture("normal_user")
assert {:ok, _} = Membership.approve_join_request(request.id, actor: user)
count_after_first = member_count()
# Second approve: either {:ok, request} with no duplicate member, or {:error, _}
result = Membership.approve_join_request(request.id, actor: user)
if match?({:ok, _}, result) do
assert member_count() == count_after_first
else
assert {:error, _} = result
end
end
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
user = Fixtures.user_with_role_fixture("normal_user")
assert {:error, _} = Membership.approve_join_request(request.id, actor: user)
end
test "approve when status is rejected returns error" do
request = Fixtures.submitted_join_request_fixture()
user = Fixtures.user_with_role_fixture("normal_user")
assert {:ok, _} = Membership.reject_join_request(request.id, actor: user)
assert {:error, _} = Membership.approve_join_request(request.id, actor: user)
end
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")
assert {:ok, _} = Membership.approve_join_request(request.id, actor: user)
request_email = request.email
[member] =
Member
|> Ash.Query.filter(expr(^ref(:email) == ^request_email))
|> Ash.read!(actor: SystemActor.get_system_actor(), domain: Membership)
assert member.join_date != nil
assert member.membership_fee_type_id != nil
end
end
end

View file

@ -0,0 +1,119 @@
defmodule Mv.Membership.JoinRequestApprovalPolicyTest do
@moduledoc """
Policy tests for JoinRequest approval UI (Step 2).
Asserts that approve/reject and list are allowed for normal_user and admin,
and forbidden for read_only, own_data, and actor: nil.
No UI; domain and resource policies only.
"""
use Mv.DataCase, async: true
alias Mv.Fixtures
alias Mv.Membership
describe "list_join_requests/1" do
test "normal_user can list join requests" do
user = Fixtures.user_with_role_fixture("normal_user")
assert {:ok, _list} = Membership.list_join_requests(actor: user)
end
test "admin can list join requests" do
user = Fixtures.user_with_role_fixture("admin")
assert {:ok, _list} = Membership.list_join_requests(actor: user)
end
test "read_only cannot list join requests" do
user = Fixtures.user_with_role_fixture("read_only")
assert {:error, %Ash.Error.Forbidden{}} = Membership.list_join_requests(actor: user)
end
test "own_data cannot list join requests" do
user = Fixtures.user_with_role_fixture("own_data")
assert {:error, %Ash.Error.Forbidden{}} = Membership.list_join_requests(actor: user)
end
test "actor nil cannot list join requests" do
assert {:error, %Ash.Error.Forbidden{}} = Membership.list_join_requests(actor: nil)
end
end
describe "approve_join_request/2" do
setup do
request = Fixtures.submitted_join_request_fixture()
%{request: request}
end
test "normal_user can approve a submitted join request", %{request: request} do
user = Fixtures.user_with_role_fixture("normal_user")
assert {:ok, approved} = Membership.approve_join_request(request.id, actor: user)
assert approved.status == :approved
assert approved.approved_at != nil
assert approved.reviewed_by_user_id == user.id
end
test "admin can approve a submitted join request", %{request: request} do
user = Fixtures.user_with_role_fixture("admin")
assert {:ok, approved} = Membership.approve_join_request(request.id, actor: user)
assert approved.status == :approved
end
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
test "actor nil cannot approve", %{request: request} do
assert {:error, %Ash.Error.Forbidden{}} =
Membership.approve_join_request(request.id, actor: nil)
end
end
describe "reject_join_request/2" do
setup do
request = Fixtures.submitted_join_request_fixture()
%{request: request}
end
test "normal_user can reject a submitted join request", %{request: request} do
user = Fixtures.user_with_role_fixture("normal_user")
assert {:ok, rejected} = Membership.reject_join_request(request.id, actor: user)
assert rejected.status == :rejected
assert rejected.rejected_at != nil
assert rejected.reviewed_by_user_id == user.id
end
test "admin can reject a submitted join request", %{request: request} do
user = Fixtures.user_with_role_fixture("admin")
assert {:ok, rejected} = Membership.reject_join_request(request.id, actor: user)
assert rejected.status == :rejected
end
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
test "actor nil cannot reject", %{request: request} do
assert {:error, %Ash.Error.Forbidden{}} =
Membership.reject_join_request(request.id, actor: nil)
end
end
end

View file

@ -212,6 +212,72 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do
end
end
describe "join_requests routes (approval UI, Step 2)" do
test "normal_user can access /join_requests" do
user = Fixtures.user_with_role_fixture("normal_user")
conn = conn_with_user("/join_requests", user) |> CheckPagePermission.call([])
refute conn.halted
end
test "normal_user can access /join_requests/:id" do
request = Fixtures.submitted_join_request_fixture()
user = Fixtures.user_with_role_fixture("normal_user")
conn = conn_with_user("/join_requests/#{request.id}", user) |> CheckPagePermission.call([])
refute conn.halted
end
test "read_only cannot access /join_requests" do
user = Fixtures.user_with_role_fixture("read_only")
conn = conn_with_user("/join_requests", user) |> CheckPagePermission.call([])
assert conn.halted
assert redirected_to(conn) == "/users/#{user.id}"
end
test "read_only cannot access /join_requests/:id" do
request = Fixtures.submitted_join_request_fixture()
user = Fixtures.user_with_role_fixture("read_only")
conn = conn_with_user("/join_requests/#{request.id}", user) |> CheckPagePermission.call([])
assert conn.halted
assert redirected_to(conn) == "/users/#{user.id}"
end
test "own_data cannot access /join_requests" do
user = Fixtures.user_with_role_fixture("own_data")
conn = conn_with_user("/join_requests", user) |> CheckPagePermission.call([])
assert conn.halted
assert redirected_to(conn) == "/users/#{user.id}"
end
test "own_data cannot access /join_requests/:id" do
request = Fixtures.submitted_join_request_fixture()
user = Fixtures.user_with_role_fixture("own_data")
conn = conn_with_user("/join_requests/#{request.id}", user) |> CheckPagePermission.call([])
assert conn.halted
assert redirected_to(conn) == "/users/#{user.id}"
end
test "admin can access /join_requests" do
user = Fixtures.user_with_role_fixture("admin")
conn = conn_with_user("/join_requests", user) |> CheckPagePermission.call([])
refute conn.halted
end
test "admin can access /join_requests/:id" do
request = Fixtures.submitted_join_request_fixture()
user = Fixtures.user_with_role_fixture("admin")
conn = conn_with_user("/join_requests/#{request.id}", user) |> CheckPagePermission.call([])
refute conn.halted
end
end
describe "error handling" do
test "user with no role is denied" do
user = Fixtures.user_with_role_fixture("admin")
@ -429,6 +495,22 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do
conn = get(conn, "/admin/roles/#{id}/edit")
assert redirected_to(conn) == "/users/#{user.id}"
end
@tag role: :member
test "GET /join_requests redirects to user profile", %{conn: conn, current_user: user} do
conn = get(conn, "/join_requests")
assert redirected_to(conn) == "/users/#{user.id}"
end
@tag role: :member
test "GET /join_requests/:id redirects to user profile", %{
conn: conn,
current_user: user
} do
request = Fixtures.submitted_join_request_fixture()
conn = get(conn, "/join_requests/#{request.id}")
assert redirected_to(conn) == "/users/#{user.id}"
end
end
describe "integration: Mitglied (own_data) can access allowed paths via full router" do
@ -634,15 +716,45 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do
conn = get(conn, "/admin/roles/#{id}")
assert redirected_to(conn) == "/users/#{user.id}"
end
@tag role: :read_only
test "GET /join_requests redirects to user profile", %{conn: conn, current_user: user} do
conn = get(conn, "/join_requests")
assert redirected_to(conn) == "/users/#{user.id}"
end
# normal_user (Kassenwart): allowed /, /members, /members/new, /members/:id, /members/:id/edit, /groups, /groups/:slug
@tag role: :read_only
test "GET /join_requests/:id redirects to user profile", %{
conn: conn,
current_user: user
} do
request = Fixtures.submitted_join_request_fixture()
conn = get(conn, "/join_requests/#{request.id}")
assert redirected_to(conn) == "/users/#{user.id}"
end
end
# 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()
{:ok, conn: conn, current_user: current_user, member_id: member.id, group_slug: group.slug}
# 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,
member_id: member.id,
group_slug: group.slug,
join_request_id: join_request.id}
end
@tag role: :normal_user
@ -725,6 +837,18 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do
conn = get(conn, "/users/#{user.id}/show/edit")
assert conn.status == 200
end
@tag role: :normal_user
test "GET /join_requests returns 200", %{conn: conn} do
conn = get(conn, "/join_requests")
assert conn.status == 200
end
@tag role: :normal_user
test "GET /join_requests/:id returns 200", %{conn: conn, join_request_id: id} do
conn = get(conn, "/join_requests/#{id}")
assert conn.status == 200
end
end
describe "integration: normal_user denied paths via full router" do

View file

@ -299,4 +299,40 @@ defmodule Mv.Fixtures do
{:error, error} -> raise "Failed to create group: #{inspect(error)}"
end
end
@doc """
Creates a join request in status :submitted (for approval UI tests).
Uses the public flow: submit_join_request then confirm_join_request with a known token.
Returns the JoinRequest struct so tests can use its id for approve/reject.
## Parameters
- `attrs` - Optional map: :email, :first_name, :last_name, :form_data, :schema_version.
Defaults: unique email; confirmation_token is generated and used internally.
## Returns
- JoinRequest struct with status :submitted
## Examples
iex> request = submitted_join_request_fixture()
iex> request.status
:submitted
iex> request = submitted_join_request_fixture(%{first_name: "Jane", last_name: "Doe"})
"""
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)
{:ok, request} = Membership.confirm_join_request(token, actor: nil)
request
end
end