diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md
index b789088..6f8deb5 100644
--- a/CODE_GUIDELINES.md
+++ b/CODE_GUIDELINES.md
@@ -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)
diff --git a/docs/onboarding-join-concept.md b/docs/onboarding-join-concept.md
index 680799d..8083a7b 100644
--- a/docs/onboarding-join-concept.md
+++ b/docs/onboarding-join-concept.md
@@ -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 1–4) 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_user’s 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_user’s 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 plug’s 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.
diff --git a/docs/page-permission-route-coverage.md b/docs/page-permission-route-coverage.md
index 38625e6..6571a39 100644
--- a/docs/page-permission-route-coverage.md
+++ b/docs/page-permission-route-coverage.md
@@ -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)
diff --git a/lib/membership/join_request.ex b/lib/membership/join_request.ex
index cf220a0..05a9e8d 100644
--- a/lib/membership/join_request.ex
+++ b/lib/membership/join_request.ex
@@ -40,6 +40,13 @@ defmodule Mv.Membership.JoinRequest do
change Mv.Membership.JoinRequest.Changes.FilterFormDataByAllowlist
end
+ # Internal/seeding only: create with status submitted (no policy allows; use authorize?: false).
+ create :create_submitted do
+ description "Create a join request with status submitted (seeds, internal use only)"
+ accept [:email, :first_name, :last_name, :form_data, :schema_version]
+ change Mv.Membership.JoinRequest.Changes.SetSubmittedForSeeding
+ end
+
read :get_by_confirmation_token_hash do
description "Find a join request by confirmation token hash (for confirm flow only)"
argument :confirmation_token_hash, :string, allow_nil?: false
@@ -56,25 +63,64 @@ defmodule Mv.Membership.JoinRequest do
change Mv.Membership.JoinRequest.Changes.ConfirmRequest
end
+
+ update :approve do
+ description "Approve a submitted join request and promote to Member"
+ require_atomic? false
+
+ change Mv.Membership.JoinRequest.Changes.ApproveRequest
+ end
+
+ update :reject do
+ description "Reject a submitted join request"
+ require_atomic? false
+
+ change Mv.Membership.JoinRequest.Changes.RejectRequest
+ end
end
policies do
- policy action(:submit) do
+ # Use :strict so unauthorized access returns Forbidden (not empty list).
+ # Default :filter would silently return [] for unauthorized reads instead of Forbidden.
+ default_access_type :strict
+
+ # Public actions: bypass so nil actor is immediately authorized (skips all remaining policies).
+ # Using bypass (not policy) avoids AND-combination with the read policy below.
+ bypass action(:submit) do
description "Allow unauthenticated submit (public join form)"
authorize_if Mv.Authorization.Checks.ActorIsNil
end
- policy action(:get_by_confirmation_token_hash) do
+ bypass action(:get_by_confirmation_token_hash) do
description "Allow unauthenticated lookup by token hash for confirm"
authorize_if Mv.Authorization.Checks.ActorIsNil
end
- policy action(:confirm) do
+ bypass action(:confirm) do
description "Allow unauthenticated confirm (confirmation link click)"
authorize_if Mv.Authorization.Checks.ActorIsNil
end
- # Default read/destroy: no policy for actor nil → Forbidden
+ # READ: bypass for authorized roles (normal_user, admin).
+ # Uses a SimpleCheck (HasJoinRequestAccess) to avoid HasPermission.auto_filter returning
+ # expr(false), which would silently produce an empty list instead of Forbidden for
+ # unauthorized actors. See docs/policy-bypass-vs-haspermission.md.
+ # Unauthorized actors fall through to no matching policy → Ash default deny (Forbidden).
+ bypass action_type(:read) do
+ description "Allow normal_user and admin to read join requests (SimpleCheck bypass)"
+ authorize_if Mv.Authorization.Checks.HasJoinRequestAccess
+ end
+
+ # Approve/Reject: only actors with JoinRequest update permission
+ policy action(:approve) do
+ description "Allow authenticated users with JoinRequest update permission to approve"
+ authorize_if Mv.Authorization.Checks.HasPermission
+ end
+
+ policy action(:reject) do
+ description "Allow authenticated users with JoinRequest update permission to reject"
+ authorize_if Mv.Authorization.Checks.HasPermission
+ end
end
validations do
@@ -135,6 +181,13 @@ defmodule Mv.Membership.JoinRequest do
update_timestamp :updated_at
end
+ relationships do
+ belongs_to :reviewed_by_user, Mv.Accounts.User do
+ define_attribute? false
+ source_attribute :reviewed_by_user_id
+ end
+ end
+
# Public helpers (used by SetConfirmationToken change and domain confirm_join_request)
@doc """
diff --git a/lib/membership/join_request/changes/approve_request.ex b/lib/membership/join_request/changes/approve_request.ex
new file mode 100644
index 0000000..24716f6
--- /dev/null
+++ b/lib/membership/join_request/changes/approve_request.ex
@@ -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
diff --git a/lib/membership/join_request/changes/helpers.ex b/lib/membership/join_request/changes/helpers.ex
new file mode 100644
index 0000000..ee09b75
--- /dev/null
+++ b/lib/membership/join_request/changes/helpers.ex
@@ -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
diff --git a/lib/membership/join_request/changes/reject_request.ex b/lib/membership/join_request/changes/reject_request.ex
new file mode 100644
index 0000000..2c33a77
--- /dev/null
+++ b/lib/membership/join_request/changes/reject_request.ex
@@ -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
diff --git a/lib/membership/join_request/changes/set_submitted_for_seeding.ex b/lib/membership/join_request/changes/set_submitted_for_seeding.ex
new file mode 100644
index 0000000..c53b6d1
--- /dev/null
+++ b/lib/membership/join_request/changes/set_submitted_for_seeding.ex
@@ -0,0 +1,15 @@
+defmodule Mv.Membership.JoinRequest.Changes.SetSubmittedForSeeding do
+ @moduledoc """
+ Sets status to :submitted and submitted_at for seed/internal creation.
+
+ Used only by the :create_submitted action (e.g. seeds, no policy allows it for normal actors).
+ """
+ use Ash.Resource.Change
+
+ @spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t()
+ def change(changeset, _opts, _context) do
+ changeset
+ |> Ash.Changeset.force_change_attribute(:status, :submitted)
+ |> Ash.Changeset.force_change_attribute(:submitted_at, DateTime.utc_now())
+ end
+end
diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex
index 3f34903..2f18f90 100644
--- a/lib/membership/membership.ex
+++ b/lib/membership/membership.ex
@@ -87,7 +87,8 @@ defmodule Mv.Membership do
end
resource Mv.Membership.JoinRequest do
- # submit_join_request/2 implemented as custom function below (create + send email)
+ # Public submit/confirm and approval domain functions are implemented as custom
+ # functions below to handle cross-resource operations (Member promotion on approve).
end
end
@@ -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
diff --git a/lib/mv/authorization/checks/has_join_request_access.ex b/lib/mv/authorization/checks/has_join_request_access.ex
new file mode 100644
index 0000000..65256c9
--- /dev/null
+++ b/lib/mv/authorization/checks/has_join_request_access.ex
@@ -0,0 +1,32 @@
+defmodule Mv.Authorization.Checks.HasJoinRequestAccess do
+ @moduledoc """
+ Simple policy check: true when the actor's role has JoinRequest read/update permission.
+
+ Used for bypass policies on JoinRequest read actions. Uses SimpleCheck (not a filter-based
+ check) so Ash does NOT call auto_filter, which would silently return an empty list for
+ unauthorized actors instead of Forbidden.
+
+ Returns true for permission sets that grant JoinRequest read :all (normal_user, admin).
+ Returns false for all others (own_data, read_only, nil actor).
+ """
+ use Ash.Policy.SimpleCheck
+
+ alias Mv.Authorization.Actor
+ alias Mv.Authorization.PermissionSets
+
+ @impl true
+ def describe(_opts), do: "actor has JoinRequest read/update access (normal_user or admin)"
+
+ @impl true
+ def match?(actor, _context, _opts) do
+ with ps_name when not is_nil(ps_name) <- Actor.permission_set_name(actor),
+ {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
+ permissions <- PermissionSets.get_permissions(ps_atom) do
+ Enum.any?(permissions.resources, fn p ->
+ p.resource == "JoinRequest" and p.action == :read and p.granted
+ end)
+ else
+ _ -> false
+ end
+ end
+end
diff --git a/lib/mv/authorization/permission_sets.ex b/lib/mv/authorization/permission_sets.ex
index fffc818..3ffae93 100644
--- a/lib/mv/authorization/permission_sets.ex
+++ b/lib/mv/authorization/permission_sets.ex
@@ -218,7 +218,11 @@ defmodule Mv.Authorization.PermissionSets do
perm("MembershipFeeCycle", :update, :all),
perm("MembershipFeeCycle", :destroy, :all)
] ++
- role_read_all(),
+ role_read_all() ++
+ [
+ perm("JoinRequest", :read, :all),
+ perm("JoinRequest", :update, :all)
+ ],
pages: [
"/",
# Own profile (sidebar links to /users/:id; redirect target must be allowed)
@@ -247,7 +251,10 @@ defmodule Mv.Authorization.PermissionSets do
# Edit group
"/groups/:slug/edit",
# Statistics
- "/statistics"
+ "/statistics",
+ # Approval UI (Step 2)
+ "/join_requests",
+ "/join_requests/:id"
]
}
end
@@ -270,7 +277,8 @@ defmodule Mv.Authorization.PermissionSets do
perm_all("Group") ++
member_group_perms ++
perm_all("MembershipFeeType") ++
- perm_all("MembershipFeeCycle"),
+ perm_all("MembershipFeeCycle") ++
+ perm_all("JoinRequest"),
pages: [
# Explicit admin-only pages (for clarity and future restrictions)
"/settings",
diff --git a/lib/mv_web/components/layouts.ex b/lib/mv_web/components/layouts.ex
index 17fca11..a6d75ba 100644
--- a/lib/mv_web/components/layouts.ex
+++ b/lib/mv_web/components/layouts.ex
@@ -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
- <.sidebar current_user={@current_user} club_name={@club_name} mobile={false} />
+ <.sidebar
+ current_user={@current_user}
+ club_name={@club_name}
+ join_form_enabled={@join_form_enabled}
+ unprocessed_join_requests_count={@unprocessed_join_requests_count}
+ mobile={false}
+ />
<% else %>
@@ -121,6 +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.
diff --git a/lib/mv_web/components/layouts/sidebar.ex b/lib/mv_web/components/layouts/sidebar.ex
index cb94fb3..49d9cae 100644
--- a/lib/mv_web/components/layouts/sidebar.ex
+++ b/lib/mv_web/components/layouts/sidebar.ex
@@ -8,6 +8,15 @@ defmodule MvWeb.Layouts.Sidebar do
attr :current_user, :map, default: nil, doc: "The current user"
attr :club_name, :string, required: true, doc: "The name of the club"
+
+ attr :join_form_enabled, :boolean,
+ default: false,
+ doc: "Whether the public join form is enabled in settings"
+
+ attr :unprocessed_join_requests_count, :integer,
+ default: 0,
+ doc: "Count of submitted (unprocessed) join requests for sidebar indicator"
+
attr :mobile, :boolean, default: false, doc: "Whether this is mobile view"
def sidebar(assigns) do
@@ -96,6 +105,15 @@ defmodule MvWeb.Layouts.Sidebar do
/>
<% end %>
+ <%= if @join_form_enabled and can_access_page?(@current_user, PagePaths.join_requests()) do %>
+ <.menu_item
+ href={~p"/join_requests"}
+ icon="hero-inbox-arrow-down"
+ label={gettext("Join requests")}
+ indicator_dot={@unprocessed_join_requests_count > 0}
+ />
+ <% end %>
+
<%= if admin_menu_visible?(@current_user) do %>
<.menu_group
icon="hero-cog-6-tooth"
@@ -137,6 +155,10 @@ defmodule MvWeb.Layouts.Sidebar do
attr :icon, :string, required: true, doc: "Heroicon name"
attr :label, :string, required: true, doc: "Menu item label"
+ attr :indicator_dot, :boolean,
+ default: false,
+ doc: "Show a small dot on the icon (e.g. for unprocessed items)"
+
defp menu_item(assigns) do
~H"""
@@ -146,7 +168,16 @@ defmodule MvWeb.Layouts.Sidebar do
data-tip={@label}
role="menuitem"
>
- <.icon name={@icon} class="size-5 shrink-0" aria-hidden="true" />
+
+ <.icon name={@icon} class="size-5 shrink-0" aria-hidden="true" />
+ <%= if @indicator_dot do %>
+
+
+ <% end %>
+
diff --git a/lib/mv_web/helpers/date_formatter.ex b/lib/mv_web/helpers/date_formatter.ex
index eaa9271..8674e21 100644
--- a/lib/mv_web/helpers/date_formatter.ex
+++ b/lib/mv_web/helpers/date_formatter.ex
@@ -24,4 +24,23 @@ defmodule MvWeb.Helpers.DateFormatter do
def format_date(nil), do: ""
def format_date(_), do: "Invalid date"
+
+ @doc """
+ Formats a DateTime struct to European format (dd.mm.yyyy HH:MM).
+
+ ## Examples
+
+ iex> MvWeb.Helpers.DateFormatter.format_datetime(~U[2024-03-15 10:30:00Z])
+ "15.03.2024 10:30"
+
+ iex> MvWeb.Helpers.DateFormatter.format_datetime(nil)
+ ""
+ """
+ def format_datetime(%DateTime{} = dt) do
+ Calendar.strftime(dt, "%d.%m.%Y %H:%M")
+ end
+
+ def format_datetime(nil), do: ""
+
+ def format_datetime(_), do: "Invalid datetime"
end
diff --git a/lib/mv_web/live/join_request_live/helpers.ex b/lib/mv_web/live/join_request_live/helpers.ex
new file mode 100644
index 0000000..5ec5105
--- /dev/null
+++ b/lib/mv_web/live/join_request_live/helpers.ex
@@ -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
diff --git a/lib/mv_web/live/join_request_live/index.ex b/lib/mv_web/live/join_request_live/index.ex
new file mode 100644
index 0000000..8d85837
--- /dev/null
+++ b/lib/mv_web/live/join_request_live/index.ex
@@ -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"""
+
+ <.header>
+ {gettext("Join requests")}
+
+
+
+
+
{gettext("Open requests")}
+ <%= if Enum.empty?(@join_requests) do %>
+
+
{gettext("No submitted join requests")}
+
+ <% else %>
+ <.table
+ id="join-requests-table"
+ rows={@join_requests}
+ row_id={fn req -> "join-request-#{req.id}" end}
+ row_click={fn req -> JS.navigate(~p"/join_requests/#{req.id}") end}
+ row_tooltip={gettext("Click for details")}
+ >
+ <:col :let={req} label={gettext("Submitted at")}>
+ <%= if req.submitted_at do %>
+ {DateFormatter.format_datetime(req.submitted_at)}
+ <% else %>
+ <.empty_cell sr_text={gettext("Not submitted yet")} />
+ <% end %>
+
+ <:col :let={req} label={gettext("First name")}>
+ <.maybe_value value={req.first_name} empty_sr_text={gettext("Not specified")}>
+ {req.first_name}
+
+
+ <:col :let={req} label={gettext("Last name")}>
+ <.maybe_value value={req.last_name} empty_sr_text={gettext("Not specified")}>
+ {req.last_name}
+
+
+ <:col :let={req} label={gettext("Email")}>
+ {req.email}
+
+ <:col :let={req} label={gettext("Status")}>
+ <.badge variant={JoinRequestHelpers.status_badge_variant(req.status)}>
+ {JoinRequestHelpers.format_status(req.status)}
+
+
+
+ <% end %>
+
+
+
+
{gettext("History")}
+ <%= if Enum.empty?(@join_requests_history) do %>
+
+
+ {gettext("No approved or rejected requests yet")}
+
+
+ <% else %>
+ <.table
+ id="join-requests-history-table"
+ rows={@join_requests_history}
+ row_id={fn req -> "join-request-history-#{req.id}" end}
+ row_click={fn req -> JS.navigate(~p"/join_requests/#{req.id}") end}
+ row_tooltip={gettext("Click for details")}
+ >
+ <:col :let={req} label={gettext("Email")}>
+ {req.email}
+
+ <:col :let={req} label={gettext("First name")}>
+ <.maybe_value value={req.first_name} empty_sr_text={gettext("Not specified")}>
+ {req.first_name}
+
+
+ <:col :let={req} label={gettext("Last name")}>
+ <.maybe_value value={req.last_name} empty_sr_text={gettext("Not specified")}>
+ {req.last_name}
+
+
+ <:col :let={req} label={gettext("Status")}>
+ <.badge variant={JoinRequestHelpers.status_badge_variant(req.status)}>
+ {JoinRequestHelpers.format_status(req.status)}
+
+
+ <:col :let={req} label={gettext("Reviewed at")}>
+ {review_date(req)}
+
+ <:col :let={req} label={gettext("Review by")}>
+ {JoinRequestHelpers.reviewer_display(req) || ""}
+
+
+ <% 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 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
diff --git a/lib/mv_web/live/join_request_live/show.ex b/lib/mv_web/live/join_request_live/show.ex
new file mode 100644
index 0000000..138b433
--- /dev/null
+++ b/lib/mv_web/live/join_request_live/show.ex
@@ -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"""
+
+ <.header>
+ <:leading>
+ <.button
+ navigate={~p"/join_requests"}
+ variant="neutral"
+ aria-label={gettext("Back to join requests")}
+ >
+ <.icon name="hero-arrow-left" class="size-4" />
+ {gettext("Back")}
+
+
+ {gettext("Join request")}
+
+
+ <%= if @join_request do %>
+
+
+
{gettext("Request data")}
+
+ <.field_row label={gettext("Email")} value={@join_request.email} />
+ <.field_row
+ label={gettext("First name")}
+ value={@join_request.first_name}
+ empty_text={gettext("Not specified")}
+ />
+ <.field_row
+ label={gettext("Last name")}
+ value={@join_request.last_name}
+ empty_text={gettext("Not specified")}
+ />
+ <.field_row
+ label={gettext("Submitted at")}
+ value={DateFormatter.format_datetime(@join_request.submitted_at)}
+ />
+
+ {gettext("Status")}:
+
+ <.badge variant={JoinRequestHelpers.status_badge_variant(@join_request.status)}>
+ {JoinRequestHelpers.format_status(@join_request.status)}
+
+
+
+
+
+
+ <%= if map_size(@join_request.form_data || %{}) > 0 do %>
+
+
{gettext("Additional form data")}
+
+ <%= for {key, value} <- format_form_data(@join_request.form_data, @join_form_field_ids || []) do %>
+ <.field_row label={key} value={to_string(value)} />
+ <% end %>
+
+
+ <% end %>
+
+ <%= if @join_request.status in [:approved, :rejected] do %>
+
+
{gettext("Review information")}
+
+ <%= if @join_request.approved_at do %>
+ <.field_row
+ label={gettext("Approved at")}
+ value={DateFormatter.format_datetime(@join_request.approved_at)}
+ />
+ <% end %>
+ <%= if @join_request.rejected_at do %>
+ <.field_row
+ label={gettext("Rejected at")}
+ value={DateFormatter.format_datetime(@join_request.rejected_at)}
+ />
+ <% end %>
+ <.field_row
+ label={gettext("Review by")}
+ value={JoinRequestHelpers.reviewer_display(@join_request)}
+ empty_text="-"
+ />
+
+
+ <% end %>
+
+ <%= if @join_request.status == :submitted do %>
+
+ <.button
+ variant="danger"
+ phx-click="reject"
+ data-confirm={gettext("Reject this join request?")}
+ data-testid="join-request-reject-btn"
+ >
+ {gettext("Reject")}
+
+ <.button
+ variant="primary"
+ phx-click="approve"
+ data-confirm={gettext("Approve this join request and create a member?")}
+ data-testid="join-request-approve-btn"
+ >
+ {gettext("Approve")}
+
+
+ <% end %>
+
+ <% end %>
+
+ """
+ end
+
+ attr :label, :string, required: true
+ attr :value, :any, default: nil
+ attr :empty_text, :string, default: nil
+
+ defp field_row(assigns) do
+ ~H"""
+
+ {@label}:
+
+ <%= if @value && @value != "" do %>
+ {@value}
+ <% else %>
+
+ {@empty_text || gettext("Not specified")}
+
+ <% end %>
+
+
+ """
+ end
+
+ # 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
diff --git a/lib/mv_web/page_paths.ex b/lib/mv_web/page_paths.ex
index 551cada..70e0ddb 100644
--- a/lib/mv_web/page_paths.ex
+++ b/lib/mv_web/page_paths.ex
@@ -9,6 +9,7 @@ defmodule MvWeb.PagePaths do
# Sidebar top-level menu paths
@members "/members"
@statistics "/statistics"
+ @join_requests "/join_requests"
# Administration submenu paths (all must match router)
@users "/users"
@@ -35,6 +36,9 @@ defmodule MvWeb.PagePaths do
@doc "Path for Statistics page (sidebar and page permission check)."
def statistics, do: @statistics
+ @doc "Path for Join Requests approval UI (sidebar and page permission check)."
+ def join_requests, do: @join_requests
+
@doc "Paths for Administration menu; show group if user can access any of these."
def admin_menu_paths, do: @admin_page_paths
diff --git a/lib/mv_web/router.ex b/lib/mv_web/router.ex
index 74fcd22..945e22c 100644
--- a/lib/mv_web/router.ex
+++ b/lib/mv_web/router.ex
@@ -83,6 +83,10 @@ defmodule MvWeb.Router do
live "/groups/:slug", GroupLive.Show, :show
live "/groups/:slug/edit", GroupLive.Form, :edit
+ # Join Request Approval (normal_user and admin)
+ live "/join_requests", JoinRequestLive.Index, :index
+ live "/join_requests/:id", JoinRequestLive.Show, :show
+
# Role Management (Admin only)
live "/admin/roles", RoleLive.Index, :index
live "/admin/roles/new", RoleLive.Form, :new
diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po
index 96b8c07..055f36a 100644
--- a/priv/gettext/de/LC_MESSAGES/default.po
+++ b/priv/gettext/de/LC_MESSAGES/default.po
@@ -59,6 +59,8 @@ msgid "Edit Member"
msgstr "Mitglied bearbeiten"
#: lib/mv_web/live/group_live/show.ex
+#: lib/mv_web/live/join_request_live/index.ex
+#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
@@ -542,6 +544,8 @@ msgstr "Benutzer*innen"
msgid "Click to sort"
msgstr "Klicke, um zu sortieren"
+#: lib/mv_web/live/join_request_live/index.ex
+#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "First name"
@@ -745,6 +749,7 @@ msgstr "Adresse"
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/group_live/show.ex
+#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
@@ -895,6 +900,8 @@ msgstr "Nur möglich, wenn diesem Typ keine Mitglieder zugewiesen sind."
msgid "Quarterly"
msgstr "Vierteljährlich"
+#: lib/mv_web/live/join_request_live/index.ex
+#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Status"
@@ -926,6 +933,8 @@ msgstr "Unbezahlt"
msgid "Yearly"
msgstr "Jährlich"
+#: lib/mv_web/live/join_request_live/index.ex
+#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Last name"
@@ -3181,6 +3190,8 @@ msgstr "Keine Gruppenzuordnung"
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/group_live/index.ex
+#: lib/mv_web/live/join_request_live/index.ex
+#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
@@ -3449,3 +3460,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"
diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot
index 65197e1..a1e0909 100644
--- a/priv/gettext/default.pot
+++ b/priv/gettext/default.pot
@@ -60,6 +60,8 @@ msgid "Edit Member"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
+#: lib/mv_web/live/join_request_live/index.ex
+#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
@@ -543,6 +545,8 @@ msgstr ""
msgid "Click to sort"
msgstr ""
+#: lib/mv_web/live/join_request_live/index.ex
+#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "First name"
@@ -746,6 +750,7 @@ msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/group_live/show.ex
+#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
@@ -896,6 +901,8 @@ msgstr ""
msgid "Quarterly"
msgstr ""
+#: lib/mv_web/live/join_request_live/index.ex
+#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Status"
@@ -927,6 +934,8 @@ msgstr ""
msgid "Yearly"
msgstr ""
+#: lib/mv_web/live/join_request_live/index.ex
+#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Last name"
@@ -3181,6 +3190,8 @@ msgstr ""
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/group_live/index.ex
+#: lib/mv_web/live/join_request_live/index.ex
+#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
@@ -3449,3 +3460,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 ""
diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po
index 4ebce69..eccae34 100644
--- a/priv/gettext/en/LC_MESSAGES/default.po
+++ b/priv/gettext/en/LC_MESSAGES/default.po
@@ -60,6 +60,8 @@ msgid "Edit Member"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
+#: lib/mv_web/live/join_request_live/index.ex
+#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
@@ -543,6 +545,8 @@ msgstr ""
msgid "Click to sort"
msgstr ""
+#: lib/mv_web/live/join_request_live/index.ex
+#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy
msgid "First name"
@@ -746,6 +750,7 @@ msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/group_live/show.ex
+#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
@@ -896,6 +901,8 @@ msgstr ""
msgid "Quarterly"
msgstr ""
+#: lib/mv_web/live/join_request_live/index.ex
+#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Status"
@@ -927,6 +934,8 @@ msgstr ""
msgid "Yearly"
msgstr ""
+#: lib/mv_web/live/join_request_live/index.ex
+#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy
msgid "Last name"
@@ -3181,6 +3190,8 @@ msgstr ""
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/group_live/index.ex
+#: lib/mv_web/live/join_request_live/index.ex
+#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
@@ -3449,3 +3460,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"
diff --git a/priv/repo/seeds_dev.exs b/priv/repo/seeds_dev.exs
index 0186bfb..436507f 100644
--- a/priv/repo/seeds_dev.exs
+++ b/priv/repo/seeds_dev.exs
@@ -481,8 +481,50 @@ for {email, values} <- custom_value_assignments do
end
end
+# Join form: enable so membership application list is visible in dev
+case Membership.get_settings() do
+ {:ok, settings} ->
+ unless settings.join_form_enabled do
+ Membership.update_settings(settings, %{
+ join_form_enabled: true,
+ join_form_field_ids: settings.join_form_field_ids || ["email", "first_name", "last_name", "city"],
+ join_form_field_required: settings.join_form_field_required || %{
+ "email" => true,
+ "first_name" => false,
+ "last_name" => false,
+ "city" => false
+ }
+ })
+ end
+ _ ->
+ :ok
+end
+
+# Membership applications (join requests) for UI testing: 4 submitted, 1 with extra form_data
+join_request_configs = [
+ %{email: "antrag1@example.de", first_name: "Sandra", last_name: "Meier", form_data: %{"city" => "Berlin"}},
+ %{email: "antrag2@example.de", first_name: "Thomas", last_name: "Bauer", form_data: %{}},
+ %{email: "antrag3@example.de", first_name: "Julia", last_name: "Krause", form_data: %{"city" => "Hamburg", "notes" => "Interesse an Jugendgruppe"}},
+ %{email: "antrag4@example.de", first_name: "Michael", last_name: "Schmitt", form_data: %{"city" => "München"}}
+]
+
+for config <- join_request_configs do
+ attrs = %{
+ email: config.email,
+ first_name: config.first_name,
+ last_name: config.last_name,
+ form_data: config.form_data || %{},
+ schema_version: 1
+ }
+
+ Mv.Membership.JoinRequest
+ |> Ash.Changeset.for_create(:create_submitted, attrs)
+ |> Ash.create!(authorize?: false, domain: Mv.Membership)
+end
+
IO.puts("✅ Dev seeds completed.")
IO.puts(" - Members: 20 with country (mostly Deutschland, 1 Österreich, 1 Schweiz), fee types distributed, 5 with exit date")
IO.puts(" - Test users: 4 linked to mitglied1–4 with roles Mitglied, Vorstand, Kassenwart, Buchhaltung")
IO.puts(" - Groups: Vorstand, Jugend, Newsletter (with assignments)")
IO.puts(" - Custom field values: ~80% filled (16 members, 4–6 fields each)")
+IO.puts(" - Join form enabled; 4 membership applications (join requests) for UI testing")
diff --git a/test/membership/join_request_approval_domain_test.exs b/test/membership/join_request_approval_domain_test.exs
new file mode 100644
index 0000000..1f9b3c2
--- /dev/null
+++ b/test/membership/join_request_approval_domain_test.exs
@@ -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
diff --git a/test/membership/join_request_approval_policy_test.exs b/test/membership/join_request_approval_policy_test.exs
new file mode 100644
index 0000000..6c09526
--- /dev/null
+++ b/test/membership/join_request_approval_policy_test.exs
@@ -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
diff --git a/test/mv_web/plugs/check_page_permission_test.exs b/test/mv_web/plugs/check_page_permission_test.exs
index d177e26..2798161 100644
--- a/test/mv_web/plugs/check_page_permission_test.exs
+++ b/test/mv_web/plugs/check_page_permission_test.exs
@@ -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
+
+ @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
+ # 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
diff --git a/test/support/fixtures.ex b/test/support/fixtures.ex
index d7cddda..73bf12a 100644
--- a/test/support/fixtures.ex
+++ b/test/support/fixtures.ex
@@ -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