From 50433e607f294f3fdc22136b381a759c94c1679b Mon Sep 17 00:00:00 2001 From: Simon Date: Tue, 10 Mar 2026 23:21:57 +0100 Subject: [PATCH 1/3] test: add tests for approval ui --- docs/onboarding-join-concept.md | 64 +++++++- docs/page-permission-route-coverage.md | 5 +- .../join_request_approval_domain_test.exs | 139 ++++++++++++++++++ .../join_request_approval_policy_test.exs | 115 +++++++++++++++ .../plugs/check_page_permission_test.exs | 120 ++++++++++++++- test/support/fixtures.ex | 34 +++++ 6 files changed, 466 insertions(+), 11 deletions(-) create mode 100644 test/membership/join_request_approval_domain_test.exs create mode 100644 test/membership/join_request_approval_policy_test.exs diff --git a/docs/onboarding-join-concept.md b/docs/onboarding-join-concept.md index 680799d..d3e8c42 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,56 @@ ## 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. +- **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 +199,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 +227,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 +262,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 +271,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/test/membership/join_request_approval_domain_test.exs b/test/membership/join_request_approval_domain_test.exs new file mode 100644 index 0000000..9578fea --- /dev/null +++ b/test/membership/join_request_approval_domain_test.exs @@ -0,0 +1,139 @@ +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 + 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..e658435 --- /dev/null +++ b/test/membership/join_request_approval_policy_test.exs @@ -0,0 +1,115 @@ +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 80aa95e..31922b0 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 @@ -713,15 +795,37 @@ 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 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} + {: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 @@ -804,6 +908,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..56347c9 100644 --- a/test/support/fixtures.ex +++ b/test/support/fixtures.ex @@ -299,4 +299,38 @@ 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 From 86d9242d83a0f5a766d8ae0c8c7f66301875713f Mon Sep 17 00:00:00 2001 From: Simon Date: Wed, 11 Mar 2026 02:04:03 +0100 Subject: [PATCH 2/3] feat: add approval ui for join requests --- lib/membership/join_request.ex | 61 +++- .../join_request/changes/approve_request.ex | 37 ++ .../join_request/changes/reject_request.ex | 36 ++ .../changes/set_submitted_for_seeding.ex | 15 + lib/membership/membership.ex | 201 ++++++++++- .../checks/has_join_request_access.ex | 32 ++ lib/mv/authorization/permission_sets.ex | 14 +- lib/mv_web/components/layouts.ex | 33 +- lib/mv_web/components/layouts/sidebar.ex | 33 +- lib/mv_web/helpers/date_formatter.ex | 19 ++ lib/mv_web/live/join_request_live/index.ex | 200 +++++++++++ lib/mv_web/live/join_request_live/show.ex | 320 ++++++++++++++++++ lib/mv_web/page_paths.ex | 4 + lib/mv_web/router.ex | 4 + priv/gettext/de/LC_MESSAGES/default.po | 178 ++++++++++ priv/gettext/default.pot | 178 ++++++++++ priv/gettext/en/LC_MESSAGES/default.po | 178 ++++++++++ priv/repo/seeds_dev.exs | 42 +++ .../join_request_approval_domain_test.exs | 35 ++ .../join_request_approval_policy_test.exs | 4 + .../plugs/check_page_permission_test.exs | 10 +- test/support/fixtures.ex | 2 + 22 files changed, 1624 insertions(+), 12 deletions(-) create mode 100644 lib/membership/join_request/changes/approve_request.ex create mode 100644 lib/membership/join_request/changes/reject_request.ex create mode 100644 lib/membership/join_request/changes/set_submitted_for_seeding.ex create mode 100644 lib/mv/authorization/checks/has_join_request_access.ex create mode 100644 lib/mv_web/live/join_request_live/index.ex create mode 100644 lib/mv_web/live/join_request_live/show.ex diff --git a/lib/membership/join_request.ex b/lib/membership/join_request.ex index cf220a0..05a9e8d 100644 --- a/lib/membership/join_request.ex +++ b/lib/membership/join_request.ex @@ -40,6 +40,13 @@ defmodule Mv.Membership.JoinRequest do change Mv.Membership.JoinRequest.Changes.FilterFormDataByAllowlist end + # Internal/seeding only: create with status submitted (no policy allows; use authorize?: false). + create :create_submitted do + description "Create a join request with status submitted (seeds, internal use only)" + accept [:email, :first_name, :last_name, :form_data, :schema_version] + change Mv.Membership.JoinRequest.Changes.SetSubmittedForSeeding + end + read :get_by_confirmation_token_hash do description "Find a join request by confirmation token hash (for confirm flow only)" argument :confirmation_token_hash, :string, allow_nil?: false @@ -56,25 +63,64 @@ defmodule Mv.Membership.JoinRequest do change Mv.Membership.JoinRequest.Changes.ConfirmRequest end + + update :approve do + description "Approve a submitted join request and promote to Member" + require_atomic? false + + change Mv.Membership.JoinRequest.Changes.ApproveRequest + end + + update :reject do + description "Reject a submitted join request" + require_atomic? false + + change Mv.Membership.JoinRequest.Changes.RejectRequest + end end policies do - policy action(:submit) do + # Use :strict so unauthorized access returns Forbidden (not empty list). + # Default :filter would silently return [] for unauthorized reads instead of Forbidden. + default_access_type :strict + + # Public actions: bypass so nil actor is immediately authorized (skips all remaining policies). + # Using bypass (not policy) avoids AND-combination with the read policy below. + bypass action(:submit) do description "Allow unauthenticated submit (public join form)" authorize_if Mv.Authorization.Checks.ActorIsNil end - policy action(:get_by_confirmation_token_hash) do + bypass action(:get_by_confirmation_token_hash) do description "Allow unauthenticated lookup by token hash for confirm" authorize_if Mv.Authorization.Checks.ActorIsNil end - policy action(:confirm) do + bypass action(:confirm) do description "Allow unauthenticated confirm (confirmation link click)" authorize_if Mv.Authorization.Checks.ActorIsNil end - # Default read/destroy: no policy for actor nil → Forbidden + # READ: bypass for authorized roles (normal_user, admin). + # Uses a SimpleCheck (HasJoinRequestAccess) to avoid HasPermission.auto_filter returning + # expr(false), which would silently produce an empty list instead of Forbidden for + # unauthorized actors. See docs/policy-bypass-vs-haspermission.md. + # Unauthorized actors fall through to no matching policy → Ash default deny (Forbidden). + bypass action_type(:read) do + description "Allow normal_user and admin to read join requests (SimpleCheck bypass)" + authorize_if Mv.Authorization.Checks.HasJoinRequestAccess + end + + # Approve/Reject: only actors with JoinRequest update permission + policy action(:approve) do + description "Allow authenticated users with JoinRequest update permission to approve" + authorize_if Mv.Authorization.Checks.HasPermission + end + + policy action(:reject) do + description "Allow authenticated users with JoinRequest update permission to reject" + authorize_if Mv.Authorization.Checks.HasPermission + end end validations do @@ -135,6 +181,13 @@ defmodule Mv.Membership.JoinRequest do update_timestamp :updated_at end + relationships do + belongs_to :reviewed_by_user, Mv.Accounts.User do + define_attribute? false + source_attribute :reviewed_by_user_id + end + end + # Public helpers (used by SetConfirmationToken change and domain confirm_join_request) @doc """ diff --git a/lib/membership/join_request/changes/approve_request.ex b/lib/membership/join_request/changes/approve_request.ex new file mode 100644 index 0000000..aee6874 --- /dev/null +++ b/lib/membership/join_request/changes/approve_request.ex @@ -0,0 +1,37 @@ +defmodule Mv.Membership.JoinRequest.Changes.ApproveRequest do + @moduledoc """ + Sets the join request to approved and records the reviewer. + + Only transitions from :submitted status. If already approved, returns error + (idempotency guard via status validation). Promotion to Member is handled + by the domain function approve_join_request/2 after calling this action. + """ + use Ash.Resource.Change + + @spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t() + def change(changeset, _opts, context) do + current_status = Ash.Changeset.get_data(changeset, :status) + + if current_status == :submitted do + reviewed_by_id = actor_id(context.actor) + + changeset + |> Ash.Changeset.force_change_attribute(:status, :approved) + |> Ash.Changeset.force_change_attribute(:approved_at, DateTime.utc_now()) + |> Ash.Changeset.force_change_attribute(:reviewed_by_user_id, reviewed_by_id) + else + Ash.Changeset.add_error(changeset, + field: :status, + message: "can only approve a submitted join request (current status: #{current_status})" + ) + end + end + + defp actor_id(nil), do: nil + + defp actor_id(actor) when is_map(actor) do + Map.get(actor, :id) || Map.get(actor, "id") + end + + defp actor_id(_), do: nil +end diff --git a/lib/membership/join_request/changes/reject_request.ex b/lib/membership/join_request/changes/reject_request.ex new file mode 100644 index 0000000..939df95 --- /dev/null +++ b/lib/membership/join_request/changes/reject_request.ex @@ -0,0 +1,36 @@ +defmodule Mv.Membership.JoinRequest.Changes.RejectRequest do + @moduledoc """ + Sets the join request to rejected and records the reviewer. + + Only transitions from :submitted status. Returns an error for any other status. + No reason field in MVP; audit fields (rejected_at, reviewed_by_user_id) are set. + """ + use Ash.Resource.Change + + @spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t() + def change(changeset, _opts, context) do + current_status = Ash.Changeset.get_data(changeset, :status) + + if current_status == :submitted do + reviewed_by_id = actor_id(context.actor) + + changeset + |> Ash.Changeset.force_change_attribute(:status, :rejected) + |> Ash.Changeset.force_change_attribute(:rejected_at, DateTime.utc_now()) + |> Ash.Changeset.force_change_attribute(:reviewed_by_user_id, reviewed_by_id) + else + Ash.Changeset.add_error(changeset, + field: :status, + message: "can only reject a submitted join request (current status: #{current_status})" + ) + end + end + + defp actor_id(nil), do: nil + + defp actor_id(actor) when is_map(actor) do + Map.get(actor, :id) || Map.get(actor, "id") + end + + defp actor_id(_), do: nil +end diff --git a/lib/membership/join_request/changes/set_submitted_for_seeding.ex b/lib/membership/join_request/changes/set_submitted_for_seeding.ex new file mode 100644 index 0000000..c53b6d1 --- /dev/null +++ b/lib/membership/join_request/changes/set_submitted_for_seeding.ex @@ -0,0 +1,15 @@ +defmodule Mv.Membership.JoinRequest.Changes.SetSubmittedForSeeding do + @moduledoc """ + Sets status to :submitted and submitted_at for seed/internal creation. + + Used only by the :create_submitted action (e.g. seeds, no policy allows it for normal actors). + """ + use Ash.Resource.Change + + @spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t() + def change(changeset, _opts, _context) do + changeset + |> Ash.Changeset.force_change_attribute(:status, :submitted) + |> Ash.Changeset.force_change_attribute(:submitted_at, DateTime.utc_now()) + end +end diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex index 3f34903..c04686b 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -87,7 +87,8 @@ defmodule Mv.Membership do end resource Mv.Membership.JoinRequest do - # submit_join_request/2 implemented as custom function below (create + send email) + # Public submit/confirm and approval domain functions are implemented as custom + # functions below to handle cross-resource operations (Member promotion on approve). end end @@ -507,4 +508,202 @@ defmodule Mv.Membership do defp expired?(nil), do: true defp expired?(expires_at), do: DateTime.compare(expires_at, DateTime.utc_now()) == :lt + + # --------------------------------------------------------------------------- + # Step 2: Approval domain functions + # --------------------------------------------------------------------------- + + @doc """ + Lists join requests, optionally filtered by status. + + ## Options + - `:actor` - Required. The actor for authorization (normal_user or admin). + - `:status` - Optional atom to filter by status (default: `:submitted`). + Pass `:all` to return requests of all statuses. + + ## Returns + - `{:ok, list}` - List of JoinRequests + - `{:error, error}` - Authorization or query error + """ + @spec list_join_requests(keyword()) :: {:ok, [JoinRequest.t()]} | {:error, term()} + def list_join_requests(opts \\ []) do + actor = Keyword.get(opts, :actor) + status = Keyword.get(opts, :status, :submitted) + + query = + if status == :all do + JoinRequest + |> Ash.Query.sort(inserted_at: :desc) + else + JoinRequest + |> Ash.Query.filter(expr(status == ^status)) + |> Ash.Query.sort(inserted_at: :desc) + end + + Ash.read(query, actor: actor, domain: __MODULE__) + end + + @doc """ + Lists join requests with status `:approved` or `:rejected` (history), sorted by most recent first. + + Loads `:reviewed_by_user` for displaying the reviewer. Same authorization as `list_join_requests/1`. + + ## Options + - `:actor` - Required. The actor for authorization (normal_user or admin). + + ## Returns + - `{:ok, list}` - List of JoinRequests (approved/rejected only) + - `{:error, error}` - Authorization or query error + """ + @spec list_join_requests_history(keyword()) :: {:ok, [JoinRequest.t()]} | {:error, term()} + def list_join_requests_history(opts \\ []) do + actor = Keyword.get(opts, :actor) + + query = + JoinRequest + |> Ash.Query.filter(expr(status in [:approved, :rejected])) + |> Ash.Query.sort(updated_at: :desc) + |> Ash.Query.load(:reviewed_by_user) + + Ash.read(query, actor: actor, domain: __MODULE__) + end + + @doc """ + Returns the count of join requests with status `:submitted` (unprocessed). + + Used e.g. for sidebar indicator. Same authorization as `list_join_requests/1`. + + ## Options + - `:actor` - Required. The actor for authorization (normal_user or admin). + + ## Returns + - Non-negative integer (0 on error or when unauthorized). + """ + @spec count_submitted_join_requests(keyword()) :: non_neg_integer() + def count_submitted_join_requests(opts \\ []) do + actor = Keyword.get(opts, :actor) + query = JoinRequest |> Ash.Query.filter(expr(status == :submitted)) + + case Ash.count(query, actor: actor, domain: __MODULE__) do + {:ok, count} when is_integer(count) and count >= 0 -> count + _ -> 0 + end + end + + @doc """ + Gets a single JoinRequest by id. + + ## Options + - `:actor` - Required. The actor for authorization. + + ## Returns + - `{:ok, request}` - The JoinRequest + - `{:ok, nil}` - Not found + - `{:error, error}` - Authorization or query error + """ + @spec get_join_request(String.t(), keyword()) :: {:ok, JoinRequest.t() | nil} | {:error, term()} + def get_join_request(id, opts \\ []) do + actor = Keyword.get(opts, :actor) + Ash.get(JoinRequest, id, actor: actor, load: [:reviewed_by_user], domain: __MODULE__) + end + + @doc """ + Approves a join request and promotes it to a Member. + + Finds the JoinRequest by id, calls the :approve action (which sets status to + :approved and records the reviewer), then creates a Member from the typed fields + and form_data. Idempotency: if the request is already approved, returns an error. + + ## Options + - `:actor` - Required. The reviewer (normal_user or admin). + + ## Returns + - `{:ok, approved_request}` - Approved JoinRequest + - `{:error, error}` - Status error, authorization error, or Member creation error + """ + @spec approve_join_request(String.t(), keyword()) :: {:ok, JoinRequest.t()} | {:error, term()} + def approve_join_request(id, opts \\ []) do + actor = Keyword.get(opts, :actor) + + with {:ok, request} <- Ash.get(JoinRequest, id, actor: actor, domain: __MODULE__), + {:ok, approved} <- + request + |> Ash.Changeset.for_update(:approve, %{}, actor: actor, domain: __MODULE__) + |> Ash.update(actor: actor, domain: __MODULE__), + {:ok, _member} <- promote_to_member(approved, actor) do + {:ok, approved} + end + end + + @doc """ + Rejects a join request. + + Finds the JoinRequest by id and calls the :reject action (status → :rejected, + records reviewer). No Member is created. Returns error if not in :submitted status. + + ## Options + - `:actor` - Required. The reviewer (normal_user or admin). + + ## Returns + - `{:ok, rejected_request}` - Rejected JoinRequest + - `{:error, error}` - Status error or authorization error + """ + @spec reject_join_request(String.t(), keyword()) :: {:ok, JoinRequest.t()} | {:error, term()} + def reject_join_request(id, opts \\ []) do + actor = Keyword.get(opts, :actor) + + with {:ok, request} <- Ash.get(JoinRequest, id, actor: actor, domain: __MODULE__) do + request + |> Ash.Changeset.for_update(:reject, %{}, actor: actor, domain: __MODULE__) + |> Ash.update(actor: actor, domain: __MODULE__) + end + end + + # Builds Member attrs + custom_field_values from a JoinRequest and creates the Member. + @uuid_pattern ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + @member_field_strings Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1) + + defp promote_to_member(%JoinRequest{} = request, actor) do + {member_attrs, custom_field_values} = build_member_attrs(request) + + attrs = + if Enum.empty?(custom_field_values) do + member_attrs + else + Map.put(member_attrs, :custom_field_values, custom_field_values) + end + + Ash.create(Mv.Membership.Member, attrs, + action: :create_member, + actor: actor, + domain: __MODULE__ + ) + end + + defp build_member_attrs(%JoinRequest{} = request) do + # join_date defaults to today so membership fee cycles can be generated. + base_attrs = %{ + email: request.email, + first_name: request.first_name, + last_name: request.last_name, + join_date: Date.utc_today() + } + + form_data = request.form_data || %{} + + Enum.reduce(form_data, {base_attrs, []}, fn {key, value}, {attrs, cfvs} -> + cond do + key in @member_field_strings -> + atom_key = String.to_existing_atom(key) + {Map.put(attrs, atom_key, value), cfvs} + + Regex.match?(@uuid_pattern, key) -> + cfv = %{custom_field_id: key, value: to_string(value)} + {attrs, [cfv | cfvs]} + + true -> + {attrs, cfvs} + end + end) + end end diff --git a/lib/mv/authorization/checks/has_join_request_access.ex b/lib/mv/authorization/checks/has_join_request_access.ex new file mode 100644 index 0000000..65256c9 --- /dev/null +++ b/lib/mv/authorization/checks/has_join_request_access.ex @@ -0,0 +1,32 @@ +defmodule Mv.Authorization.Checks.HasJoinRequestAccess do + @moduledoc """ + Simple policy check: true when the actor's role has JoinRequest read/update permission. + + Used for bypass policies on JoinRequest read actions. Uses SimpleCheck (not a filter-based + check) so Ash does NOT call auto_filter, which would silently return an empty list for + unauthorized actors instead of Forbidden. + + Returns true for permission sets that grant JoinRequest read :all (normal_user, admin). + Returns false for all others (own_data, read_only, nil actor). + """ + use Ash.Policy.SimpleCheck + + alias Mv.Authorization.Actor + alias Mv.Authorization.PermissionSets + + @impl true + def describe(_opts), do: "actor has JoinRequest read/update access (normal_user or admin)" + + @impl true + def match?(actor, _context, _opts) do + with ps_name when not is_nil(ps_name) <- Actor.permission_set_name(actor), + {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name), + permissions <- PermissionSets.get_permissions(ps_atom) do + Enum.any?(permissions.resources, fn p -> + p.resource == "JoinRequest" and p.action == :read and p.granted + end) + else + _ -> false + end + end +end diff --git a/lib/mv/authorization/permission_sets.ex b/lib/mv/authorization/permission_sets.ex index fffc818..3ffae93 100644 --- a/lib/mv/authorization/permission_sets.ex +++ b/lib/mv/authorization/permission_sets.ex @@ -218,7 +218,11 @@ defmodule Mv.Authorization.PermissionSets do perm("MembershipFeeCycle", :update, :all), perm("MembershipFeeCycle", :destroy, :all) ] ++ - role_read_all(), + role_read_all() ++ + [ + perm("JoinRequest", :read, :all), + perm("JoinRequest", :update, :all) + ], pages: [ "/", # Own profile (sidebar links to /users/:id; redirect target must be allowed) @@ -247,7 +251,10 @@ defmodule Mv.Authorization.PermissionSets do # Edit group "/groups/:slug/edit", # Statistics - "/statistics" + "/statistics", + # Approval UI (Step 2) + "/join_requests", + "/join_requests/:id" ] } end @@ -270,7 +277,8 @@ defmodule Mv.Authorization.PermissionSets do perm_all("Group") ++ member_group_perms ++ perm_all("MembershipFeeType") ++ - perm_all("MembershipFeeCycle"), + perm_all("MembershipFeeCycle") ++ + perm_all("JoinRequest"), pages: [ # Explicit admin-only pages (for clarity and future restrictions) "/settings", diff --git a/lib/mv_web/components/layouts.ex b/lib/mv_web/components/layouts.ex index 17fca11..25dfb1d 100644 --- a/lib/mv_web/components/layouts.ex +++ b/lib/mv_web/components/layouts.ex @@ -44,7 +44,16 @@ defmodule MvWeb.Layouts do def app(assigns) do club_name = get_club_name() - assigns = assign(assigns, :club_name, club_name) + join_form_enabled = get_join_form_enabled() + + unprocessed_join_requests_count = + get_unprocessed_join_requests_count(assigns.current_user, join_form_enabled) + + assigns = + assigns + |> assign(:club_name, club_name) + |> assign(:join_form_enabled, join_form_enabled) + |> assign(:unprocessed_join_requests_count, unprocessed_join_requests_count) ~H""" <%= if @current_user do %> @@ -78,7 +87,13 @@ defmodule MvWeb.Layouts do
- <.sidebar current_user={@current_user} club_name={@club_name} mobile={false} /> + <.sidebar + current_user={@current_user} + club_name={@club_name} + join_form_enabled={@join_form_enabled} + unprocessed_join_requests_count={@unprocessed_join_requests_count} + mobile={false} + />
<% else %> @@ -121,6 +136,20 @@ defmodule MvWeb.Layouts do end end + defp get_join_form_enabled do + case Mv.Membership.get_settings() do + {:ok, %{join_form_enabled: true}} -> true + _ -> false + end + end + + defp get_unprocessed_join_requests_count(nil, _), do: 0 + defp get_unprocessed_join_requests_count(_user, false), do: 0 + + defp get_unprocessed_join_requests_count(user, true) do + Mv.Membership.count_submitted_join_requests(actor: user) + end + @doc """ Shows the flash group with standard titles and content. diff --git a/lib/mv_web/components/layouts/sidebar.ex b/lib/mv_web/components/layouts/sidebar.ex index cb94fb3..49d9cae 100644 --- a/lib/mv_web/components/layouts/sidebar.ex +++ b/lib/mv_web/components/layouts/sidebar.ex @@ -8,6 +8,15 @@ defmodule MvWeb.Layouts.Sidebar do attr :current_user, :map, default: nil, doc: "The current user" attr :club_name, :string, required: true, doc: "The name of the club" + + attr :join_form_enabled, :boolean, + default: false, + doc: "Whether the public join form is enabled in settings" + + attr :unprocessed_join_requests_count, :integer, + default: 0, + doc: "Count of submitted (unprocessed) join requests for sidebar indicator" + attr :mobile, :boolean, default: false, doc: "Whether this is mobile view" def sidebar(assigns) do @@ -96,6 +105,15 @@ defmodule MvWeb.Layouts.Sidebar do /> <% end %> + <%= if @join_form_enabled and can_access_page?(@current_user, PagePaths.join_requests()) do %> + <.menu_item + href={~p"/join_requests"} + icon="hero-inbox-arrow-down" + label={gettext("Join requests")} + indicator_dot={@unprocessed_join_requests_count > 0} + /> + <% end %> + <%= if admin_menu_visible?(@current_user) do %> <.menu_group icon="hero-cog-6-tooth" @@ -137,6 +155,10 @@ defmodule MvWeb.Layouts.Sidebar do attr :icon, :string, required: true, doc: "Heroicon name" attr :label, :string, required: true, doc: "Menu item label" + attr :indicator_dot, :boolean, + default: false, + doc: "Show a small dot on the icon (e.g. for unprocessed items)" + defp menu_item(assigns) do ~H"""
  • @@ -146,7 +168,16 @@ defmodule MvWeb.Layouts.Sidebar do data-tip={@label} role="menuitem" > - <.icon name={@icon} class="size-5 shrink-0" aria-hidden="true" /> + + <.icon name={@icon} class="size-5 shrink-0" aria-hidden="true" /> + <%= if @indicator_dot do %> + + <% end %> + {@label}
  • diff --git a/lib/mv_web/helpers/date_formatter.ex b/lib/mv_web/helpers/date_formatter.ex index eaa9271..8674e21 100644 --- a/lib/mv_web/helpers/date_formatter.ex +++ b/lib/mv_web/helpers/date_formatter.ex @@ -24,4 +24,23 @@ defmodule MvWeb.Helpers.DateFormatter do def format_date(nil), do: "" def format_date(_), do: "Invalid date" + + @doc """ + Formats a DateTime struct to European format (dd.mm.yyyy HH:MM). + + ## Examples + + iex> MvWeb.Helpers.DateFormatter.format_datetime(~U[2024-03-15 10:30:00Z]) + "15.03.2024 10:30" + + iex> MvWeb.Helpers.DateFormatter.format_datetime(nil) + "" + """ + def format_datetime(%DateTime{} = dt) do + Calendar.strftime(dt, "%d.%m.%Y %H:%M") + end + + def format_datetime(nil), do: "" + + def format_datetime(_), do: "Invalid datetime" end diff --git a/lib/mv_web/live/join_request_live/index.ex b/lib/mv_web/live/join_request_live/index.ex new file mode 100644 index 0000000..87797e9 --- /dev/null +++ b/lib/mv_web/live/join_request_live/index.ex @@ -0,0 +1,200 @@ +defmodule MvWeb.JoinRequestLive.Index do + @moduledoc """ + LiveView for listing and reviewing join requests (approval UI, Step 2). + + ## Features + - List join requests filtered by status (default: submitted) + - Navigate to detail view for approve/reject actions + - Accessible to normal_user and admin roles only + + ## Security + - Page access controlled by CheckPagePermission plug and can_access_page? guard + - Ash policy (HasPermission) enforces JoinRequest read :all for normal_user and admin + """ + use MvWeb, :live_view + + require Logger + + import MvWeb.LiveHelpers, only: [current_actor: 1] + import MvWeb.Authorization + + alias Mv.Membership + alias MvWeb.Helpers.DateFormatter + + @impl true + def mount(_params, _session, socket) do + actor = current_actor(socket) + + cond do + not join_form_enabled?() -> + {:ok, redirect(socket, to: ~p"/members")} + + not can_access_page?(actor, "/join_requests") -> + {:ok, redirect(socket, to: ~p"/members")} + + true -> + {:ok, load_join_requests(socket, actor)} + end + end + + @impl true + def render(assigns) do + ~H""" + + <.header> + {gettext("Join requests")} + + +
    +
    +

    {gettext("Open requests")}

    + <%= if Enum.empty?(@join_requests) do %> +
    +

    {gettext("No submitted join requests")}

    +
    + <% else %> + <.table + id="join-requests-table" + rows={@join_requests} + row_id={fn req -> "join-request-#{req.id}" end} + row_click={fn req -> JS.navigate(~p"/join_requests/#{req.id}") end} + row_tooltip={gettext("Click for details")} + > + <:col :let={req} label={gettext("Submitted at")}> + <%= if req.submitted_at do %> + {DateFormatter.format_datetime(req.submitted_at)} + <% else %> + <.empty_cell sr_text={gettext("Not submitted yet")} /> + <% end %> + + <:col :let={req} label={gettext("First name")}> + <.maybe_value value={req.first_name} empty_sr_text={gettext("Not specified")}> + {req.first_name} + + + <:col :let={req} label={gettext("Last name")}> + <.maybe_value value={req.last_name} empty_sr_text={gettext("Not specified")}> + {req.last_name} + + + <:col :let={req} label={gettext("Email")}> + {req.email} + + <:col :let={req} label={gettext("Status")}> + <.badge variant={status_badge_variant(req.status)}> + {format_status(req.status)} + + + + <% end %> +
    + +
    +

    {gettext("History")}

    + <%= if Enum.empty?(@join_requests_history) do %> +
    +

    + {gettext("No approved or rejected requests yet")} +

    +
    + <% else %> + <.table + id="join-requests-history-table" + rows={@join_requests_history} + row_id={fn req -> "join-request-history-#{req.id}" end} + row_click={fn req -> JS.navigate(~p"/join_requests/#{req.id}") end} + row_tooltip={gettext("Click for details")} + > + <:col :let={req} label={gettext("Email")}> + {req.email} + + <:col :let={req} label={gettext("First name")}> + <.maybe_value value={req.first_name} empty_sr_text={gettext("Not specified")}> + {req.first_name} + + + <:col :let={req} label={gettext("Last name")}> + <.maybe_value value={req.last_name} empty_sr_text={gettext("Not specified")}> + {req.last_name} + + + <:col :let={req} label={gettext("Status")}> + <.badge variant={status_badge_variant(req.status)}> + {format_status(req.status)} + + + <:col :let={req} label={gettext("Reviewed at")}> + {review_date(req)} + + <:col :let={req} label={gettext("Review by")}> + {reviewer_display(req)} + + + <% end %> +
    +
    +
    + """ + end + + defp join_form_enabled? do + case Membership.get_settings() do + {:ok, %{join_form_enabled: true}} -> true + _ -> false + end + end + + defp load_join_requests(socket, actor) do + socket = + case Membership.list_join_requests(actor: actor, status: :submitted) do + {:ok, requests} -> + assign(socket, :join_requests, requests) + + {:error, error} -> + Logger.warning("Failed to load join requests: #{inspect(error)}") + assign(socket, :join_requests, []) + end + + socket = + case Membership.list_join_requests_history(actor: actor) do + {:ok, history} -> + assign(socket, :join_requests_history, history) + + {:error, error} -> + Logger.warning("Failed to load join requests history: #{inspect(error)}") + assign(socket, :join_requests_history, []) + end + + assign(socket, :page_title, gettext("Join requests")) + end + + defp format_status(:pending_confirmation), do: gettext("Pending confirmation") + defp format_status(:submitted), do: gettext("Submitted") + defp format_status(:approved), do: gettext("Approved") + defp format_status(:rejected), do: gettext("Rejected") + defp format_status(other), do: to_string(other) + + defp status_badge_variant(:submitted), do: :info + defp status_badge_variant(:approved), do: :success + defp status_badge_variant(:rejected), do: :error + defp status_badge_variant(_), do: :neutral + + defp review_date(req) do + date = + case req.status do + :approved -> req.approved_at + :rejected -> req.rejected_at + _ -> nil + end + + if date, do: DateFormatter.format_datetime(date), else: "" + end + + defp reviewer_display(req) do + case req.reviewed_by_user do + nil -> "" + %{email: email} when not is_nil(email) -> to_string(email) |> String.trim() + _ -> "" + end + end +end diff --git a/lib/mv_web/live/join_request_live/show.ex b/lib/mv_web/live/join_request_live/show.ex new file mode 100644 index 0000000..72579b3 --- /dev/null +++ b/lib/mv_web/live/join_request_live/show.ex @@ -0,0 +1,320 @@ +defmodule MvWeb.JoinRequestLive.Show do + @moduledoc """ + LiveView for displaying a single join request and performing approve/reject actions. + + ## Features + - Show all request data (typed fields + form_data rendered by field) + - Approve action: transitions to :approved, creates Member + - Reject action: transitions to :rejected (no Member created) + - Actions only available when status is :submitted + + ## Security + - Page access controlled by CheckPagePermission plug and can_access_page? guard + - Ash policy (HasPermission) enforces JoinRequest update :all for normal_user and admin + """ + use MvWeb, :live_view + + require Logger + + import MvWeb.LiveHelpers, only: [current_actor: 1] + import MvWeb.Authorization + + alias Mv.Constants + alias Mv.Membership + alias MvWeb.Helpers.DateFormatter + + @impl true + def mount(_params, _session, socket) do + if join_form_enabled?() do + {:ok, + socket + |> assign(:join_request, nil) + |> assign(:join_form_field_ids, []) + |> assign(:page_title, gettext("Join request"))} + else + {:ok, redirect(socket, to: ~p"/members")} + end + end + + @impl true + def handle_params(%{"id" => id}, _url, socket) do + actor = current_actor(socket) + + if join_form_enabled?() and can_access_page?(actor, "/join_requests/:id") do + case Membership.get_join_request(id, actor: actor) do + {:ok, nil} -> + {:noreply, + socket + |> put_flash(:error, gettext("Join request not found.")) + |> push_navigate(to: ~p"/join_requests")} + + {:ok, request} -> + field_ids = Membership.get_join_form_allowlist() |> Enum.map(& &1.id) + + {:noreply, + socket + |> assign(:join_request, request) + |> assign(:join_form_field_ids, field_ids) + |> assign(:page_title, gettext("Join request – %{email}", email: request.email))} + + {:error, _error} -> + {:noreply, + socket + |> put_flash(:error, gettext("Failed to load join request.")) + |> push_navigate(to: ~p"/join_requests")} + end + else + {:noreply, redirect(socket, to: ~p"/members")} + end + end + + @impl true + def handle_event("approve", _params, socket) do + actor = current_actor(socket) + request = socket.assigns.join_request + + case Membership.approve_join_request(request.id, actor: actor) do + {:ok, _approved} -> + {:noreply, + socket + |> put_flash(:info, gettext("Join request approved. Member created.")) + |> push_navigate(to: ~p"/join_requests")} + + {:error, error} -> + Logger.warning("Failed to approve join request #{request.id}: #{inspect(error)}") + + {:noreply, put_flash(socket, :error, gettext("Failed to approve join request."))} + end + end + + @impl true + def handle_event("reject", _params, socket) do + actor = current_actor(socket) + request = socket.assigns.join_request + + case Membership.reject_join_request(request.id, actor: actor) do + {:ok, _rejected} -> + {:noreply, + socket + |> put_flash(:info, gettext("Join request rejected.")) + |> push_navigate(to: ~p"/join_requests")} + + {:error, error} -> + Logger.warning("Failed to reject join request #{request.id}: #{inspect(error)}") + + {:noreply, put_flash(socket, :error, gettext("Failed to reject join request."))} + end + end + + defp join_form_enabled? do + case Membership.get_settings() do + {:ok, %{join_form_enabled: true}} -> true + _ -> false + end + end + + @impl true + def render(assigns) do + ~H""" + + <.header> + <:leading> + <.button + navigate={~p"/join_requests"} + variant="neutral" + aria-label={gettext("Back to join requests")} + > + <.icon name="hero-arrow-left" class="size-4" /> + {gettext("Back")} + + + {gettext("Join request")} + + + <%= if @join_request do %> +
    +
    +

    {gettext("Request data")}

    +
    + <.field_row label={gettext("Email")} value={@join_request.email} /> + <.field_row + label={gettext("First name")} + value={@join_request.first_name} + empty_text={gettext("Not specified")} + /> + <.field_row + label={gettext("Last name")} + value={@join_request.last_name} + empty_text={gettext("Not specified")} + /> + <.field_row + label={gettext("Submitted at")} + value={DateFormatter.format_datetime(@join_request.submitted_at)} + /> +
    + {gettext("Status")}: + + <.badge variant={status_badge_variant(@join_request.status)}> + {format_status(@join_request.status)} + + +
    +
    +
    + + <%= if map_size(@join_request.form_data || %{}) > 0 do %> +
    +

    {gettext("Additional form data")}

    +
    + <%= for {key, value} <- format_form_data(@join_request.form_data, @join_form_field_ids || []) do %> + <.field_row label={key} value={to_string(value)} /> + <% end %> +
    +
    + <% end %> + + <%= if @join_request.status in [:approved, :rejected] do %> +
    +

    {gettext("Review information")}

    +
    + <%= if @join_request.approved_at do %> + <.field_row + label={gettext("Approved at")} + value={DateFormatter.format_datetime(@join_request.approved_at)} + /> + <% end %> + <%= if @join_request.rejected_at do %> + <.field_row + label={gettext("Rejected at")} + value={DateFormatter.format_datetime(@join_request.rejected_at)} + /> + <% end %> + <.field_row + label={gettext("Review by")} + value={reviewer_display(@join_request)} + empty_text="-" + /> +
    +
    + <% end %> + + <%= if @join_request.status == :submitted do %> +
    + <.button + variant="danger" + phx-click="reject" + data-confirm={gettext("Reject this join request?")} + data-testid="join-request-reject-btn" + > + {gettext("Reject")} + + <.button + variant="primary" + phx-click="approve" + data-confirm={gettext("Approve this join request and create a member?")} + data-testid="join-request-approve-btn" + > + {gettext("Approve")} + +
    + <% end %> +
    + <% end %> +
    + """ + end + + attr :label, :string, required: true + attr :value, :any, default: nil + attr :empty_text, :string, default: nil + + defp field_row(assigns) do + ~H""" +
    + {@label}: + + <%= if @value && @value != "" do %> + {@value} + <% else %> + + {@empty_text || gettext("Not specified")} + + <% end %> + +
    + """ + end + + defp format_status(:pending_confirmation), do: gettext("Pending confirmation") + defp format_status(:submitted), do: gettext("Submitted") + defp format_status(:approved), do: gettext("Approved") + defp format_status(:rejected), do: gettext("Rejected") + defp format_status(other), do: to_string(other) + + defp status_badge_variant(:submitted), do: :info + defp status_badge_variant(:approved), do: :success + defp status_badge_variant(:rejected), do: :error + defp status_badge_variant(_), do: :neutral + + defp reviewer_display(%{reviewed_by_user: user}) do + case user do + nil -> + nil + + %{email: email} when not is_nil(email) -> + s = to_string(email) |> String.trim() + if s == "", do: nil, else: s + + _ -> + nil + end + end + + defp reviewer_display(_), do: nil + + # Formats form_data for display in join-form order; legacy keys (not in current + # join_form_field_ids) are appended at the end, sorted by label for stability. + # Labels: member field keys → human-readable; UUID keys kept as-is (custom field IDs). + defp format_form_data(nil, _ordered_field_ids), do: [] + + defp format_form_data(form_data, ordered_field_ids) when is_map(form_data) do + member_field_strings = Constants.member_fields() |> Enum.map(&Atom.to_string/1) + + # First: entries in current join form order (only keys present in form_data) + in_order = + ordered_field_ids + |> Enum.filter(&Map.has_key?(form_data, &1)) + |> Enum.map(fn key -> + value = form_data[key] + label = field_key_to_label(key, member_field_strings) + {label, value} + end) + + # Then: keys in form_data that are not in current settings (e.g. removed fields on old requests) + legacy_keys = + form_data + |> Map.keys() + |> Enum.reject(&(&1 in ordered_field_ids)) + |> Enum.sort() + + legacy_entries = + Enum.map(legacy_keys, fn key -> + label = field_key_to_label(key, member_field_strings) + {label, form_data[key]} + end) + + in_order ++ legacy_entries + end + + defp field_key_to_label(key, member_field_strings) when is_binary(key) do + if key in member_field_strings, do: humanize_field(key), else: key + end + + defp field_key_to_label(key, _), do: to_string(key) + + defp humanize_field(key) when is_binary(key) do + key + |> String.replace("_", " ") + |> String.capitalize() + end +end diff --git a/lib/mv_web/page_paths.ex b/lib/mv_web/page_paths.ex index 551cada..70e0ddb 100644 --- a/lib/mv_web/page_paths.ex +++ b/lib/mv_web/page_paths.ex @@ -9,6 +9,7 @@ defmodule MvWeb.PagePaths do # Sidebar top-level menu paths @members "/members" @statistics "/statistics" + @join_requests "/join_requests" # Administration submenu paths (all must match router) @users "/users" @@ -35,6 +36,9 @@ defmodule MvWeb.PagePaths do @doc "Path for Statistics page (sidebar and page permission check)." def statistics, do: @statistics + @doc "Path for Join Requests approval UI (sidebar and page permission check)." + def join_requests, do: @join_requests + @doc "Paths for Administration menu; show group if user can access any of these." def admin_menu_paths, do: @admin_page_paths diff --git a/lib/mv_web/router.ex b/lib/mv_web/router.ex index 74fcd22..945e22c 100644 --- a/lib/mv_web/router.ex +++ b/lib/mv_web/router.ex @@ -83,6 +83,10 @@ defmodule MvWeb.Router do live "/groups/:slug", GroupLive.Show, :show live "/groups/:slug/edit", GroupLive.Form, :edit + # Join Request Approval (normal_user and admin) + live "/join_requests", JoinRequestLive.Index, :index + live "/join_requests/:id", JoinRequestLive.Show, :show + # Role Management (Admin only) live "/admin/roles", RoleLive.Index, :index live "/admin/roles/new", RoleLive.Form, :new diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 96b8c07..71c42be 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -59,6 +59,8 @@ msgid "Edit Member" msgstr "Mitglied bearbeiten" #: lib/mv_web/live/group_live/show.ex +#: lib/mv_web/live/join_request_live/index.ex +#: lib/mv_web/live/join_request_live/show.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/show.ex @@ -542,6 +544,8 @@ msgstr "Benutzer*innen" msgid "Click to sort" msgstr "Klicke, um zu sortieren" +#: lib/mv_web/live/join_request_live/index.ex +#: lib/mv_web/live/join_request_live/show.ex #: lib/mv_web/live/member_live/index.html.heex #, elixir-autogen, elixir-format msgid "First name" @@ -745,6 +749,7 @@ msgstr "Adresse" #: lib/mv_web/live/custom_field_live/form_component.ex #: lib/mv_web/live/group_live/form.ex #: lib/mv_web/live/group_live/show.ex +#: lib/mv_web/live/join_request_live/show.ex #: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex @@ -895,6 +900,8 @@ msgstr "Nur möglich, wenn diesem Typ keine Mitglieder zugewiesen sind." msgid "Quarterly" msgstr "Vierteljährlich" +#: lib/mv_web/live/join_request_live/index.ex +#: lib/mv_web/live/join_request_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "Status" @@ -926,6 +933,8 @@ msgstr "Unbezahlt" msgid "Yearly" msgstr "Jährlich" +#: lib/mv_web/live/join_request_live/index.ex +#: lib/mv_web/live/join_request_live/show.ex #: lib/mv_web/live/member_live/index.html.heex #, elixir-autogen, elixir-format msgid "Last name" @@ -3181,6 +3190,8 @@ msgstr "Keine Gruppenzuordnung" #: lib/mv_web/components/core_components.ex #: lib/mv_web/live/group_live/index.ex +#: lib/mv_web/live/join_request_live/index.ex +#: lib/mv_web/live/join_request_live/show.ex #: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format @@ -3449,3 +3460,170 @@ msgstr "Deine Angaben werden nur zur Bearbeitung deines Mitgliedsantrags und zur #, elixir-autogen, elixir-format msgid "Website" msgstr "Webseite" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Additional form data" +msgstr "Weitere Formulardaten" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Approve" +msgstr "Genehmigen" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Approve this join request and create a member?" +msgstr "Diesen Mitgliedsantrag genehmigen und Mitglied anlegen?" + +#: lib/mv_web/live/join_request_live/index.ex +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Approved" +msgstr "Genehmigt" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Approved at" +msgstr "Genehmigt am" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Back to join requests" +msgstr "Zurück zu den Mitgliedsanträgen" + +#: lib/mv_web/live/join_request_live/index.ex +#, elixir-autogen, elixir-format +msgid "Click for details" +msgstr "Klicken für Details" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Failed to approve join request." +msgstr "Mitgliedsantrag konnte nicht genehmigt werden." + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Failed to load join request." +msgstr "Mitgliedsantrag konnte nicht geladen werden." + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Failed to reject join request." +msgstr "Mitgliedsantrag konnte nicht abgelehnt werden." + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Join request" +msgstr "Mitgliedsantrag" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Join request approved. Member created." +msgstr "Mitgliedsantrag genehmigt. Mitglied wurde angelegt." + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Join request not found." +msgstr "Mitgliedsantrag nicht gefunden." + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Join request rejected." +msgstr "Mitgliedsantrag abgelehnt." + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Join request – %{email}" +msgstr "Mitgliedsantrag – %{email}" + +#: lib/mv_web/components/layouts/sidebar.ex +#: lib/mv_web/live/join_request_live/index.ex +#, elixir-autogen, elixir-format +msgid "Join requests" +msgstr "Mitgliedsanträge" + +#: lib/mv_web/live/join_request_live/index.ex +#, elixir-autogen, elixir-format +msgid "No submitted join requests" +msgstr "Keine eingereichten Mitgliedsanträge" + +#: lib/mv_web/live/join_request_live/index.ex +#, elixir-autogen, elixir-format +msgid "Not submitted yet" +msgstr "Noch nicht eingereicht" + +#: lib/mv_web/live/join_request_live/index.ex +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Pending confirmation" +msgstr "Bestätigung ausstehend" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Reject" +msgstr "Ablehnen" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Reject this join request?" +msgstr "Diesen Mitgliedsantrag ablehnen?" + +#: lib/mv_web/live/join_request_live/index.ex +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Rejected" +msgstr "Abgelehnt" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Rejected at" +msgstr "Abgelehnt am" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Request data" +msgstr "Antragsdaten" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Review information" +msgstr "Bearbeitungsinformationen" + +#: lib/mv_web/live/join_request_live/index.ex +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Submitted" +msgstr "Eingereicht" + +#: lib/mv_web/live/join_request_live/index.ex +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Submitted at" +msgstr "Eingereicht am" + +#: lib/mv_web/live/join_request_live/index.ex +#, elixir-autogen, elixir-format +msgid "No approved or rejected requests yet" +msgstr "Noch keine genehmigten oder abgelehnten Anträge" + +#: lib/mv_web/live/join_request_live/index.ex +#, elixir-autogen, elixir-format +msgid "Reviewed at" +msgstr "Geprüft am" + +#: lib/mv_web/live/join_request_live/index.ex +#, elixir-autogen, elixir-format +msgid "History" +msgstr "Historie" + +#: lib/mv_web/live/join_request_live/index.ex +#, elixir-autogen, elixir-format +msgid "Open requests" +msgstr "Offene Anträge" + +#: lib/mv_web/live/join_request_live/index.ex +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Review by" +msgstr "Geprüft von" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 65197e1..b04f216 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -60,6 +60,8 @@ msgid "Edit Member" msgstr "" #: lib/mv_web/live/group_live/show.ex +#: lib/mv_web/live/join_request_live/index.ex +#: lib/mv_web/live/join_request_live/show.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/show.ex @@ -543,6 +545,8 @@ msgstr "" msgid "Click to sort" msgstr "" +#: lib/mv_web/live/join_request_live/index.ex +#: lib/mv_web/live/join_request_live/show.ex #: lib/mv_web/live/member_live/index.html.heex #, elixir-autogen, elixir-format msgid "First name" @@ -746,6 +750,7 @@ msgstr "" #: lib/mv_web/live/custom_field_live/form_component.ex #: lib/mv_web/live/group_live/form.ex #: lib/mv_web/live/group_live/show.ex +#: lib/mv_web/live/join_request_live/show.ex #: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex @@ -896,6 +901,8 @@ msgstr "" msgid "Quarterly" msgstr "" +#: lib/mv_web/live/join_request_live/index.ex +#: lib/mv_web/live/join_request_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "Status" @@ -927,6 +934,8 @@ msgstr "" msgid "Yearly" msgstr "" +#: lib/mv_web/live/join_request_live/index.ex +#: lib/mv_web/live/join_request_live/show.ex #: lib/mv_web/live/member_live/index.html.heex #, elixir-autogen, elixir-format msgid "Last name" @@ -3181,6 +3190,8 @@ msgstr "" #: lib/mv_web/components/core_components.ex #: lib/mv_web/live/group_live/index.ex +#: lib/mv_web/live/join_request_live/index.ex +#: lib/mv_web/live/join_request_live/show.ex #: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format @@ -3449,3 +3460,170 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Website" msgstr "" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Additional form data" +msgstr "" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Approve" +msgstr "" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Approve this join request and create a member?" +msgstr "" + +#: lib/mv_web/live/join_request_live/index.ex +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Approved" +msgstr "" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Approved at" +msgstr "" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Back to join requests" +msgstr "" + +#: lib/mv_web/live/join_request_live/index.ex +#, elixir-autogen, elixir-format +msgid "Click for details" +msgstr "" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Failed to approve join request." +msgstr "" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Failed to load join request." +msgstr "" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Failed to reject join request." +msgstr "" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Join request" +msgstr "" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Join request approved. Member created." +msgstr "" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Join request not found." +msgstr "" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Join request rejected." +msgstr "" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Join request – %{email}" +msgstr "" + +#: lib/mv_web/components/layouts/sidebar.ex +#: lib/mv_web/live/join_request_live/index.ex +#, elixir-autogen, elixir-format +msgid "Join requests" +msgstr "" + +#: lib/mv_web/live/join_request_live/index.ex +#, elixir-autogen, elixir-format +msgid "No submitted join requests" +msgstr "" + +#: lib/mv_web/live/join_request_live/index.ex +#, elixir-autogen, elixir-format +msgid "Not submitted yet" +msgstr "" + +#: lib/mv_web/live/join_request_live/index.ex +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Pending confirmation" +msgstr "" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Reject" +msgstr "" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Reject this join request?" +msgstr "" + +#: lib/mv_web/live/join_request_live/index.ex +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Rejected" +msgstr "" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Rejected at" +msgstr "" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Request data" +msgstr "" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Review information" +msgstr "" + +#: lib/mv_web/live/join_request_live/index.ex +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Submitted" +msgstr "" + +#: lib/mv_web/live/join_request_live/index.ex +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Submitted at" +msgstr "" + +#: lib/mv_web/live/join_request_live/index.ex +#, elixir-autogen, elixir-format +msgid "No approved or rejected requests yet" +msgstr "" + +#: lib/mv_web/live/join_request_live/index.ex +#, elixir-autogen, elixir-format +msgid "Reviewed at" +msgstr "" + +#: lib/mv_web/live/join_request_live/index.ex +#, elixir-autogen, elixir-format +msgid "History" +msgstr "" + +#: lib/mv_web/live/join_request_live/index.ex +#, elixir-autogen, elixir-format +msgid "Open requests" +msgstr "" + +#: lib/mv_web/live/join_request_live/index.ex +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Review by" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 4ebce69..0269a31 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -60,6 +60,8 @@ msgid "Edit Member" msgstr "" #: lib/mv_web/live/group_live/show.ex +#: lib/mv_web/live/join_request_live/index.ex +#: lib/mv_web/live/join_request_live/show.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/show.ex @@ -543,6 +545,8 @@ msgstr "" msgid "Click to sort" msgstr "" +#: lib/mv_web/live/join_request_live/index.ex +#: lib/mv_web/live/join_request_live/show.ex #: lib/mv_web/live/member_live/index.html.heex #, elixir-autogen, elixir-format, fuzzy msgid "First name" @@ -746,6 +750,7 @@ msgstr "" #: lib/mv_web/live/custom_field_live/form_component.ex #: lib/mv_web/live/group_live/form.ex #: lib/mv_web/live/group_live/show.ex +#: lib/mv_web/live/join_request_live/show.ex #: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex @@ -896,6 +901,8 @@ msgstr "" msgid "Quarterly" msgstr "" +#: lib/mv_web/live/join_request_live/index.ex +#: lib/mv_web/live/join_request_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "Status" @@ -927,6 +934,8 @@ msgstr "" msgid "Yearly" msgstr "" +#: lib/mv_web/live/join_request_live/index.ex +#: lib/mv_web/live/join_request_live/show.ex #: lib/mv_web/live/member_live/index.html.heex #, elixir-autogen, elixir-format, fuzzy msgid "Last name" @@ -3181,6 +3190,8 @@ msgstr "" #: lib/mv_web/components/core_components.ex #: lib/mv_web/live/group_live/index.ex +#: lib/mv_web/live/join_request_live/index.ex +#: lib/mv_web/live/join_request_live/show.ex #: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format @@ -3449,3 +3460,170 @@ msgstr "Your details are only used to process your membership application and to #, elixir-autogen, elixir-format msgid "Website" msgstr "" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Additional form data" +msgstr "Additional form data" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Approve" +msgstr "Approve" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Approve this join request and create a member?" +msgstr "Approve this membership application and create a member?" + +#: lib/mv_web/live/join_request_live/index.ex +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Approved" +msgstr "Approved" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Approved at" +msgstr "Approved at" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Back to join requests" +msgstr "Back to membership applications" + +#: lib/mv_web/live/join_request_live/index.ex +#, elixir-autogen, elixir-format +msgid "Click for details" +msgstr "Click for details" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Failed to approve join request." +msgstr "Failed to approve membership application." + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Failed to load join request." +msgstr "Failed to load membership application." + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Failed to reject join request." +msgstr "Failed to reject membership application." + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Join request" +msgstr "Membership application" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Join request approved. Member created." +msgstr "Membership application approved. Member created." + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Join request not found." +msgstr "Membership application not found." + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Join request rejected." +msgstr "Membership application rejected." + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Join request – %{email}" +msgstr "Membership application – %{email}" + +#: lib/mv_web/components/layouts/sidebar.ex +#: lib/mv_web/live/join_request_live/index.ex +#, elixir-autogen, elixir-format +msgid "Join requests" +msgstr "Membership applications" + +#: lib/mv_web/live/join_request_live/index.ex +#, elixir-autogen, elixir-format +msgid "No submitted join requests" +msgstr "No submitted membership applications" + +#: lib/mv_web/live/join_request_live/index.ex +#, elixir-autogen, elixir-format +msgid "Not submitted yet" +msgstr "Not submitted yet" + +#: lib/mv_web/live/join_request_live/index.ex +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Pending confirmation" +msgstr "Pending confirmation" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Reject" +msgstr "Reject" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Reject this join request?" +msgstr "Reject this membership application?" + +#: lib/mv_web/live/join_request_live/index.ex +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Rejected" +msgstr "Rejected" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Rejected at" +msgstr "Rejected at" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Request data" +msgstr "Request data" + +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Review information" +msgstr "Review information" + +#: lib/mv_web/live/join_request_live/index.ex +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Submitted" +msgstr "Submitted" + +#: lib/mv_web/live/join_request_live/index.ex +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Submitted at" +msgstr "Submitted at" + +#: lib/mv_web/live/join_request_live/index.ex +#, elixir-autogen, elixir-format +msgid "No approved or rejected requests yet" +msgstr "No approved or rejected requests yet" + +#: lib/mv_web/live/join_request_live/index.ex +#, elixir-autogen, elixir-format +msgid "Reviewed at" +msgstr "Review date" + +#: lib/mv_web/live/join_request_live/index.ex +#, elixir-autogen, elixir-format +msgid "History" +msgstr "" + +#: lib/mv_web/live/join_request_live/index.ex +#, elixir-autogen, elixir-format +msgid "Open requests" +msgstr "" + +#: lib/mv_web/live/join_request_live/index.ex +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Review by" +msgstr "Review by" diff --git a/priv/repo/seeds_dev.exs b/priv/repo/seeds_dev.exs index 352299f..5f30a08 100644 --- a/priv/repo/seeds_dev.exs +++ b/priv/repo/seeds_dev.exs @@ -481,8 +481,50 @@ for {email, values} <- custom_value_assignments do end end +# Join form: enable so membership application list is visible in dev +case Membership.get_settings() do + {:ok, settings} -> + unless settings.join_form_enabled do + Membership.update_settings(settings, %{ + join_form_enabled: true, + join_form_field_ids: settings.join_form_field_ids || ["email", "first_name", "last_name", "city"], + join_form_field_required: settings.join_form_field_required || %{ + "email" => true, + "first_name" => false, + "last_name" => false, + "city" => false + } + }) + end + _ -> + :ok +end + +# Membership applications (join requests) for UI testing: 4 submitted, 1 with extra form_data +join_request_configs = [ + %{email: "antrag1@example.de", first_name: "Sandra", last_name: "Meier", form_data: %{"city" => "Berlin"}}, + %{email: "antrag2@example.de", first_name: "Thomas", last_name: "Bauer", form_data: %{}}, + %{email: "antrag3@example.de", first_name: "Julia", last_name: "Krause", form_data: %{"city" => "Hamburg", "notes" => "Interesse an Jugendgruppe"}}, + %{email: "antrag4@example.de", first_name: "Michael", last_name: "Schmitt", form_data: %{"city" => "München"}} +] + +for config <- join_request_configs do + attrs = %{ + email: config.email, + first_name: config.first_name, + last_name: config.last_name, + form_data: config.form_data || %{}, + schema_version: 1 + } + + Mv.Membership.JoinRequest + |> Ash.Changeset.for_create(:create_submitted, attrs) + |> Ash.create!(authorize?: false, domain: Mv.Membership) +end + IO.puts("✅ Dev seeds completed.") IO.puts(" - Members: 20 with country (mostly Deutschland, 1 Österreich, 1 Schweiz)") IO.puts(" - Test users: 4 linked to mitglied1–4 with roles Mitglied, Vorstand, Kassenwart, Buchhaltung") IO.puts(" - Groups: Vorstand, Jugend, Newsletter (with assignments)") IO.puts(" - Custom field values: ~80% filled (16 members, 4–6 fields each)") +IO.puts(" - Join form enabled; 4 membership applications (join requests) for UI testing") diff --git a/test/membership/join_request_approval_domain_test.exs b/test/membership/join_request_approval_domain_test.exs index 9578fea..1f9b3c2 100644 --- a/test/membership/join_request_approval_domain_test.exs +++ b/test/membership/join_request_approval_domain_test.exs @@ -38,6 +38,7 @@ defmodule Mv.Membership.JoinRequestApprovalDomainTest do assert member_count() == count_before + 1 request_email = request.email + [member] = Member |> Ash.Query.filter(expr(^ref(:email) == ^request_email)) @@ -56,6 +57,7 @@ defmodule Mv.Membership.JoinRequestApprovalDomainTest do # No User should exist with this email from the approval flow request_email = request.email + users_with_email = Mv.Accounts.User |> Ash.Query.filter(expr(^ref(:email) == ^request_email)) @@ -99,10 +101,12 @@ defmodule Mv.Membership.JoinRequestApprovalDomainTest do test "approve when status is pending_confirmation returns error" do token = "pending-token-#{System.unique_integer([:positive])}" + attrs = %{ email: "pending#{System.unique_integer([:positive])}@example.com", confirmation_token: token } + {:ok, request} = Membership.submit_join_request(attrs, actor: nil) assert request.status == :pending_confirmation @@ -120,6 +124,36 @@ defmodule Mv.Membership.JoinRequestApprovalDomainTest do end describe "approve_join_request/2 – defaults" do + setup do + # Create a fee type and set it as the default in settings so SetDefaultMembershipFeeType + # can assign it when a member is created from a join request (no fee type in form_data). + actor = SystemActor.get_system_actor() + + {:ok, fee_type} = + Ash.create( + Mv.MembershipFees.MembershipFeeType, + %{ + name: "Default Test Fee Type #{System.unique_integer([:positive])}", + amount: Decimal.new("50.00"), + interval: :yearly + }, + actor: actor, + domain: Mv.MembershipFees + ) + + {:ok, settings} = Membership.get_settings() + + settings + |> Ash.Changeset.for_update( + :update_membership_fee_settings, + %{default_membership_fee_type_id: fee_type.id}, + actor: actor + ) + |> Ash.update!(actor: actor) + + :ok + end + test "created member has join_date and membership_fee_type when not in form_data" do request = Fixtures.submitted_join_request_fixture() user = Fixtures.user_with_role_fixture("normal_user") @@ -127,6 +161,7 @@ defmodule Mv.Membership.JoinRequestApprovalDomainTest do assert {:ok, _} = Membership.approve_join_request(request.id, actor: user) request_email = request.email + [member] = Member |> Ash.Query.filter(expr(^ref(:email) == ^request_email)) diff --git a/test/membership/join_request_approval_policy_test.exs b/test/membership/join_request_approval_policy_test.exs index e658435..6c09526 100644 --- a/test/membership/join_request_approval_policy_test.exs +++ b/test/membership/join_request_approval_policy_test.exs @@ -59,12 +59,14 @@ defmodule Mv.Membership.JoinRequestApprovalPolicyTest do test "read_only cannot approve", %{request: request} do user = Fixtures.user_with_role_fixture("read_only") + assert {:error, %Ash.Error.Forbidden{}} = Membership.approve_join_request(request.id, actor: user) end test "own_data cannot approve", %{request: request} do user = Fixtures.user_with_role_fixture("own_data") + assert {:error, %Ash.Error.Forbidden{}} = Membership.approve_join_request(request.id, actor: user) end @@ -97,12 +99,14 @@ defmodule Mv.Membership.JoinRequestApprovalPolicyTest do test "read_only cannot reject", %{request: request} do user = Fixtures.user_with_role_fixture("read_only") + assert {:error, %Ash.Error.Forbidden{}} = Membership.reject_join_request(request.id, actor: user) end test "own_data cannot reject", %{request: request} do user = Fixtures.user_with_role_fixture("own_data") + assert {:error, %Ash.Error.Forbidden{}} = Membership.reject_join_request(request.id, actor: user) end diff --git a/test/mv_web/plugs/check_page_permission_test.exs b/test/mv_web/plugs/check_page_permission_test.exs index 31922b0..d8c46e1 100644 --- a/test/mv_web/plugs/check_page_permission_test.exs +++ b/test/mv_web/plugs/check_page_permission_test.exs @@ -813,13 +813,21 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do end end - # normal_user (Kassenwart): allowed /, /members, /members/new, /members/:id, /members/:id/edit, /groups, /groups/:slug, /join_requests + # normal_user (Kassenwart): allowed /, /members, /members/new, /members/:id, /members/:id/edit, + # /groups, /groups/:slug, /join_requests (only when join form is enabled in settings) describe "integration: normal_user (Kassenwart) allowed paths via full router" do setup %{conn: conn, current_user: current_user} do member = Mv.Fixtures.member_fixture() group = Mv.Fixtures.group_fixture() join_request = Fixtures.submitted_join_request_fixture() + # Enable join form so /join_requests and /join_requests/:id return 200 (not redirect) + {:ok, settings} = Mv.Membership.get_settings() + + if settings do + Mv.Membership.update_settings(settings, %{join_form_enabled: true}) + end + {:ok, conn: conn, current_user: current_user, diff --git a/test/support/fixtures.ex b/test/support/fixtures.ex index 56347c9..73bf12a 100644 --- a/test/support/fixtures.ex +++ b/test/support/fixtures.ex @@ -323,10 +323,12 @@ defmodule Mv.Fixtures do """ def submitted_join_request_fixture(attrs \\ %{}) do token = "fixture-token-#{System.unique_integer([:positive])}" + base = %{ email: "join#{System.unique_integer([:positive])}@example.com", confirmation_token: token } + attrs = base |> Map.merge(attrs) |> Map.put(:confirmation_token, token) {:ok, _} = Membership.submit_join_request(attrs, actor: nil) From f53a3ce3cc3c14df5d55f6ad5e1d1f86f0e13558 Mon Sep 17 00:00:00 2001 From: Simon Date: Wed, 11 Mar 2026 02:19:49 +0100 Subject: [PATCH 3/3] refactor: integrate approval ui review changes --- CODE_GUIDELINES.md | 2 +- docs/onboarding-join-concept.md | 1 + .../join_request/changes/approve_request.ex | 12 +--- .../join_request/changes/helpers.ex | 19 +++++++ .../join_request/changes/reject_request.ex | 12 +--- lib/membership/membership.ex | 57 +++++++++++++++---- lib/mv_web/components/layouts.ex | 11 +--- lib/mv_web/live/join_request_live/helpers.ex | 47 +++++++++++++++ lib/mv_web/live/join_request_live/index.ex | 39 +++---------- lib/mv_web/live/join_request_live/show.ex | 56 ++++-------------- priv/gettext/de/LC_MESSAGES/default.po | 12 ++-- priv/gettext/default.pot | 12 ++-- priv/gettext/en/LC_MESSAGES/default.po | 12 ++-- 13 files changed, 153 insertions(+), 139 deletions(-) create mode 100644 lib/membership/join_request/changes/helpers.ex create mode 100644 lib/mv_web/live/join_request_live/helpers.ex 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 d3e8c42..8083a7b 100644 --- a/docs/onboarding-join-concept.md +++ b/docs/onboarding-join-concept.md @@ -133,6 +133,7 @@ Implementation spec for Subtask 5. - **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 diff --git a/lib/membership/join_request/changes/approve_request.ex b/lib/membership/join_request/changes/approve_request.ex index aee6874..24716f6 100644 --- a/lib/membership/join_request/changes/approve_request.ex +++ b/lib/membership/join_request/changes/approve_request.ex @@ -8,12 +8,14 @@ defmodule Mv.Membership.JoinRequest.Changes.ApproveRequest do """ 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 = actor_id(context.actor) + reviewed_by_id = Helpers.actor_id(context.actor) changeset |> Ash.Changeset.force_change_attribute(:status, :approved) @@ -26,12 +28,4 @@ defmodule Mv.Membership.JoinRequest.Changes.ApproveRequest do ) end end - - defp actor_id(nil), do: nil - - defp actor_id(actor) when is_map(actor) do - Map.get(actor, :id) || Map.get(actor, "id") - end - - defp actor_id(_), do: nil end diff --git a/lib/membership/join_request/changes/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 index 939df95..2c33a77 100644 --- a/lib/membership/join_request/changes/reject_request.ex +++ b/lib/membership/join_request/changes/reject_request.ex @@ -7,12 +7,14 @@ defmodule Mv.Membership.JoinRequest.Changes.RejectRequest do """ 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 = actor_id(context.actor) + reviewed_by_id = Helpers.actor_id(context.actor) changeset |> Ash.Changeset.force_change_attribute(:status, :rejected) @@ -25,12 +27,4 @@ defmodule Mv.Membership.JoinRequest.Changes.RejectRequest do ) end end - - defp actor_id(nil), do: nil - - defp actor_id(actor) when is_map(actor) do - Map.get(actor, :id) || Map.get(actor, "id") - end - - defp actor_id(_), do: nil end diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex index c04686b..2f18f90 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -456,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. @@ -585,8 +599,15 @@ defmodule Mv.Membership do query = JoinRequest |> Ash.Query.filter(expr(status == :submitted)) case Ash.count(query, actor: actor, domain: __MODULE__) do - {:ok, count} when is_integer(count) and count >= 0 -> count - _ -> 0 + {: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 @@ -604,7 +625,13 @@ defmodule Mv.Membership do @spec get_join_request(String.t(), keyword()) :: {:ok, JoinRequest.t() | nil} | {:error, term()} def get_join_request(id, opts \\ []) do actor = Keyword.get(opts, :actor) - Ash.get(JoinRequest, id, actor: actor, load: [:reviewed_by_user], domain: __MODULE__) + + Ash.get(JoinRequest, id, + actor: actor, + load: [:reviewed_by_user], + not_found_error?: false, + domain: __MODULE__ + ) end @doc """ @@ -625,13 +652,22 @@ defmodule Mv.Membership do def approve_join_request(id, opts \\ []) do actor = Keyword.get(opts, :actor) - with {:ok, request} <- Ash.get(JoinRequest, id, actor: actor, domain: __MODULE__), - {:ok, approved} <- - request - |> Ash.Changeset.for_update(:approve, %{}, actor: actor, domain: __MODULE__) - |> Ash.update(actor: actor, domain: __MODULE__), - {:ok, _member} <- promote_to_member(approved, actor) do - {:ok, approved} + 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 @@ -661,6 +697,7 @@ defmodule Mv.Membership do # 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 diff --git a/lib/mv_web/components/layouts.ex b/lib/mv_web/components/layouts.ex index 25dfb1d..a6d75ba 100644 --- a/lib/mv_web/components/layouts.ex +++ b/lib/mv_web/components/layouts.ex @@ -44,8 +44,10 @@ defmodule MvWeb.Layouts do def app(assigns) do club_name = get_club_name() - join_form_enabled = get_join_form_enabled() + 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) @@ -136,13 +138,6 @@ defmodule MvWeb.Layouts do end end - defp get_join_form_enabled do - case Mv.Membership.get_settings() do - {:ok, %{join_form_enabled: true}} -> true - _ -> false - end - end - defp get_unprocessed_join_requests_count(nil, _), do: 0 defp get_unprocessed_join_requests_count(_user, false), do: 0 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 index 87797e9..8d85837 100644 --- a/lib/mv_web/live/join_request_live/index.ex +++ b/lib/mv_web/live/join_request_live/index.ex @@ -20,13 +20,14 @@ defmodule MvWeb.JoinRequestLive.Index do 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 join_form_enabled?() -> + not Membership.join_form_enabled?() -> {:ok, redirect(socket, to: ~p"/members")} not can_access_page?(actor, "/join_requests") -> @@ -81,8 +82,8 @@ defmodule MvWeb.JoinRequestLive.Index do {req.email} <:col :let={req} label={gettext("Status")}> - <.badge variant={status_badge_variant(req.status)}> - {format_status(req.status)} + <.badge variant={JoinRequestHelpers.status_badge_variant(req.status)}> + {JoinRequestHelpers.format_status(req.status)} @@ -119,15 +120,15 @@ defmodule MvWeb.JoinRequestLive.Index do <:col :let={req} label={gettext("Status")}> - <.badge variant={status_badge_variant(req.status)}> - {format_status(req.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")}> - {reviewer_display(req)} + {JoinRequestHelpers.reviewer_display(req) || ""} <% end %> @@ -137,13 +138,6 @@ defmodule MvWeb.JoinRequestLive.Index do """ end - defp join_form_enabled? do - case Membership.get_settings() do - {:ok, %{join_form_enabled: true}} -> true - _ -> false - end - end - defp load_join_requests(socket, actor) do socket = case Membership.list_join_requests(actor: actor, status: :submitted) do @@ -168,17 +162,6 @@ defmodule MvWeb.JoinRequestLive.Index do assign(socket, :page_title, gettext("Join requests")) end - defp format_status(:pending_confirmation), do: gettext("Pending confirmation") - defp format_status(:submitted), do: gettext("Submitted") - defp format_status(:approved), do: gettext("Approved") - defp format_status(:rejected), do: gettext("Rejected") - defp format_status(other), do: to_string(other) - - defp status_badge_variant(:submitted), do: :info - defp status_badge_variant(:approved), do: :success - defp status_badge_variant(:rejected), do: :error - defp status_badge_variant(_), do: :neutral - defp review_date(req) do date = case req.status do @@ -189,12 +172,4 @@ defmodule MvWeb.JoinRequestLive.Index do if date, do: DateFormatter.format_datetime(date), else: "" end - - defp reviewer_display(req) do - case req.reviewed_by_user do - nil -> "" - %{email: email} when not is_nil(email) -> to_string(email) |> String.trim() - _ -> "" - end - end end diff --git a/lib/mv_web/live/join_request_live/show.ex b/lib/mv_web/live/join_request_live/show.ex index 72579b3..138b433 100644 --- a/lib/mv_web/live/join_request_live/show.ex +++ b/lib/mv_web/live/join_request_live/show.ex @@ -22,10 +22,12 @@ defmodule MvWeb.JoinRequestLive.Show do 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 join_form_enabled?() do + if Membership.join_form_enabled?() do {:ok, socket |> assign(:join_request, nil) @@ -40,7 +42,7 @@ defmodule MvWeb.JoinRequestLive.Show do def handle_params(%{"id" => id}, _url, socket) do actor = current_actor(socket) - if join_form_enabled?() and can_access_page?(actor, "/join_requests/:id") do + 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, @@ -106,13 +108,6 @@ defmodule MvWeb.JoinRequestLive.Show do end end - defp join_form_enabled? do - case Membership.get_settings() do - {:ok, %{join_form_enabled: true}} -> true - _ -> false - end - end - @impl true def render(assigns) do ~H""" @@ -154,8 +149,8 @@ defmodule MvWeb.JoinRequestLive.Show do
    {gettext("Status")}: - <.badge variant={status_badge_variant(@join_request.status)}> - {format_status(@join_request.status)} + <.badge variant={JoinRequestHelpers.status_badge_variant(@join_request.status)}> + {JoinRequestHelpers.format_status(@join_request.status)}
    @@ -191,7 +186,7 @@ defmodule MvWeb.JoinRequestLive.Show do <% end %> <.field_row label={gettext("Review by")} - value={reviewer_display(@join_request)} + value={JoinRequestHelpers.reviewer_display(@join_request)} empty_text="-" /> @@ -245,33 +240,6 @@ defmodule MvWeb.JoinRequestLive.Show do """ end - defp format_status(:pending_confirmation), do: gettext("Pending confirmation") - defp format_status(:submitted), do: gettext("Submitted") - defp format_status(:approved), do: gettext("Approved") - defp format_status(:rejected), do: gettext("Rejected") - defp format_status(other), do: to_string(other) - - defp status_badge_variant(:submitted), do: :info - defp status_badge_variant(:approved), do: :success - defp status_badge_variant(:rejected), do: :error - defp status_badge_variant(_), do: :neutral - - defp reviewer_display(%{reviewed_by_user: user}) do - case user do - nil -> - nil - - %{email: email} when not is_nil(email) -> - s = to_string(email) |> String.trim() - if s == "", do: nil, else: s - - _ -> - nil - end - end - - defp reviewer_display(_), do: nil - # Formats form_data for display in join-form order; legacy keys (not in current # join_form_field_ids) are appended at the end, sorted by label for stability. # Labels: member field keys → human-readable; UUID keys kept as-is (custom field IDs). @@ -307,14 +275,10 @@ defmodule MvWeb.JoinRequestLive.Show do end defp field_key_to_label(key, member_field_strings) when is_binary(key) do - if key in member_field_strings, do: humanize_field(key), else: key + 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) - - defp humanize_field(key) when is_binary(key) do - key - |> String.replace("_", " ") - |> String.capitalize() - end end diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 71c42be..055f36a 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -3476,8 +3476,7 @@ msgstr "Genehmigen" msgid "Approve this join request and create a member?" msgstr "Diesen Mitgliedsantrag genehmigen und Mitglied anlegen?" -#: lib/mv_web/live/join_request_live/index.ex -#: lib/mv_web/live/join_request_live/show.ex +#: lib/mv_web/live/join_request_live/helpers.ex #, elixir-autogen, elixir-format msgid "Approved" msgstr "Genehmigt" @@ -3553,8 +3552,7 @@ msgstr "Keine eingereichten Mitgliedsanträge" msgid "Not submitted yet" msgstr "Noch nicht eingereicht" -#: lib/mv_web/live/join_request_live/index.ex -#: lib/mv_web/live/join_request_live/show.ex +#: lib/mv_web/live/join_request_live/helpers.ex #, elixir-autogen, elixir-format msgid "Pending confirmation" msgstr "Bestätigung ausstehend" @@ -3569,8 +3567,7 @@ msgstr "Ablehnen" msgid "Reject this join request?" msgstr "Diesen Mitgliedsantrag ablehnen?" -#: lib/mv_web/live/join_request_live/index.ex -#: lib/mv_web/live/join_request_live/show.ex +#: lib/mv_web/live/join_request_live/helpers.ex #, elixir-autogen, elixir-format msgid "Rejected" msgstr "Abgelehnt" @@ -3590,8 +3587,7 @@ msgstr "Antragsdaten" msgid "Review information" msgstr "Bearbeitungsinformationen" -#: lib/mv_web/live/join_request_live/index.ex -#: lib/mv_web/live/join_request_live/show.ex +#: lib/mv_web/live/join_request_live/helpers.ex #, elixir-autogen, elixir-format msgid "Submitted" msgstr "Eingereicht" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index b04f216..a1e0909 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -3476,8 +3476,7 @@ msgstr "" msgid "Approve this join request and create a member?" msgstr "" -#: lib/mv_web/live/join_request_live/index.ex -#: lib/mv_web/live/join_request_live/show.ex +#: lib/mv_web/live/join_request_live/helpers.ex #, elixir-autogen, elixir-format msgid "Approved" msgstr "" @@ -3553,8 +3552,7 @@ msgstr "" msgid "Not submitted yet" msgstr "" -#: lib/mv_web/live/join_request_live/index.ex -#: lib/mv_web/live/join_request_live/show.ex +#: lib/mv_web/live/join_request_live/helpers.ex #, elixir-autogen, elixir-format msgid "Pending confirmation" msgstr "" @@ -3569,8 +3567,7 @@ msgstr "" msgid "Reject this join request?" msgstr "" -#: lib/mv_web/live/join_request_live/index.ex -#: lib/mv_web/live/join_request_live/show.ex +#: lib/mv_web/live/join_request_live/helpers.ex #, elixir-autogen, elixir-format msgid "Rejected" msgstr "" @@ -3590,8 +3587,7 @@ msgstr "" msgid "Review information" msgstr "" -#: lib/mv_web/live/join_request_live/index.ex -#: lib/mv_web/live/join_request_live/show.ex +#: lib/mv_web/live/join_request_live/helpers.ex #, elixir-autogen, elixir-format msgid "Submitted" msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 0269a31..eccae34 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -3476,8 +3476,7 @@ msgstr "Approve" msgid "Approve this join request and create a member?" msgstr "Approve this membership application and create a member?" -#: lib/mv_web/live/join_request_live/index.ex -#: lib/mv_web/live/join_request_live/show.ex +#: lib/mv_web/live/join_request_live/helpers.ex #, elixir-autogen, elixir-format msgid "Approved" msgstr "Approved" @@ -3553,8 +3552,7 @@ msgstr "No submitted membership applications" msgid "Not submitted yet" msgstr "Not submitted yet" -#: lib/mv_web/live/join_request_live/index.ex -#: lib/mv_web/live/join_request_live/show.ex +#: lib/mv_web/live/join_request_live/helpers.ex #, elixir-autogen, elixir-format msgid "Pending confirmation" msgstr "Pending confirmation" @@ -3569,8 +3567,7 @@ msgstr "Reject" msgid "Reject this join request?" msgstr "Reject this membership application?" -#: lib/mv_web/live/join_request_live/index.ex -#: lib/mv_web/live/join_request_live/show.ex +#: lib/mv_web/live/join_request_live/helpers.ex #, elixir-autogen, elixir-format msgid "Rejected" msgstr "Rejected" @@ -3590,8 +3587,7 @@ msgstr "Request data" msgid "Review information" msgstr "Review information" -#: lib/mv_web/live/join_request_live/index.ex -#: lib/mv_web/live/join_request_live/show.ex +#: lib/mv_web/live/join_request_live/helpers.ex #, elixir-autogen, elixir-format msgid "Submitted" msgstr "Submitted"