add backend for join form #308 #438

Open
simon wants to merge 5 commits from feature/concept-web-form-308 into main
11 changed files with 608 additions and 30 deletions

View file

@ -335,6 +335,15 @@ end
- show custom fields in member overview per default - show custom fields in member overview per default
- can be set to false in the settings for the specific custom field - can be set to false in the settings for the specific custom field
---
**Onboarding / Public Join (Issue #308) Subtask 1: JoinRequest resource and public policies**
- JoinRequest Ash resource (`lib/membership/join_request.ex`) per concept §2.3.2: email, confirmation_token_hash, status, submitted_at, source, schema_version, payload, approved_at, rejected_at, reviewed_by_user_id
- Migration `20260220120000_add_join_requests.exs` with unique index on `confirmation_token_hash` for idempotency
- Public policies: `:confirm` and `:read` allowed with `actor: nil`; generic `:create` requires HasPermission
- Domain interface: `confirm_join_request/2`, `list_join_requests/1`, `get_join_request/2`, `update_join_request/2`, `destroy_join_request/1`
- Tests: `test/mv/membership/join_request_test.exs` public create/read with nil, idempotency, validations (no UI/email yet)
## Implementation Decisions ## Implementation Decisions
### Architecture Patterns ### Architecture Patterns

View file

@ -0,0 +1,218 @@
# Onboarding & Join High-Level Concept
**Status:** Draft for design decisions and implementation specs
**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.
---
## 1. Focus and Goals
- **Focus:** Onboarding and **initial data capture**, not self-service editing of existing members.
- **Entry paths (vision):**
- **Public Join form** (Prio 1) unauthenticated submission.
- **Invite link** (tokenized) later.
- **OIDC first-login** (Just-in-Time Provisioning) later.
- **Admin control:** All entry paths and their behaviour (e.g. which fields, approval required) shall be configurable by admins; MVP can start with sensible defaults.
- **Approval:** A Vorstand (board) approval step is a direct follow-up (Step 2) after the public Join; the data model and flow must support it.
---
## 2. Prio 1: Public Join Page
### 2.1 Intent
- **Public** page (e.g. `/join`): no login; anyone can open and submit.
- Result is **not** a User or Member. Result is an **onboarding / join request** in status “submitted” (pending).
- This keeps:
- **Public intake** (abuse-prone) separate from **identity and account creation** (after approval / invite / OIDC).
- Existing policies (e.g. UserMember linking, admin-only link) untouched until a defined “promotion” flow (e.g. after approval) creates User/Member.
### 2.2 User Flow (Prio 1)
1. Unauthenticated user opens `/join`.
2. Short explanation + form (what happens next: “We will review … you will hear from us”).
3. Submit → “Please confirm your email” (confirmation email sent; no JoinRequest created yet).
4. User clicks confirmation link → **then** the JoinRequest is created with status “submitted” and the user sees: “Thank you, we have received your request.”
**Rationale (email confirmation first):** Best practice for public signups is double opt-in: create the record only after the email address is verified. This reduces fake submissions, improves deliverability, and aligns with consent/compliance expectations (e.g. GDPR). The codebase already has a confirmation pattern (AshAuthentication user confirmation: token, email sender, confirm route); the same pattern can be reused for JoinRequest (store pending data with a short-lived token, send email, on link click create JoinRequest). Effort is moderate and acceptable.
**Out of scope for Prio 1:** Approval UI, account creation, OIDC, invite links.
### 2.3 Data Flow
- **Input:** Only data explicitly allowed for the public form; field set is admin-configured (see §2.6). No internal or sensitive fields. **Server-side allowlist:** The set of accepted fields must be enforced on the server from the join-form settings (allowlist), not only in the UI, to prevent field injection or extra attributes from being stored.
- **Pre-confirmation:** On form submit, store pending data with a short-lived confirmation token (e.g. in a transient store or a “pending” record); send confirmation email; do **not** create the JoinRequest yet. **Store choice (critical):** See §2.3.1 for options, tradeoffs, and recommendation.
- **Post-confirmation:** When the user clicks the confirmation link, create the **JoinRequest** resource with:
- Submitted payload (minimal, well-defined schema; only whitelisted fields). See §2.3.2 for payload vs typed columns and schema versioning.
- Status: `submitted` (later: `approved`, `rejected`). See §2.3.2 for audit fields and idempotency.
- Server-set metadata: `submitted_at`, locale, source (e.g. `public_join`), optional abuse signals. See §2.3.2 for abuse-metadata classification.
- **No creation** of Member or User in Prio 1; promotion to Member/User happens in a later step (e.g. after approval).
#### 2.3.1 Pre-Confirmation Store: ETS vs DB vs Stateless Token
This decision has lasting impact (multi-node, retention, UX after deploy/restart). Choose explicitly.
| Option | Pros | Cons |
|--------|------|------|
| **A DB table** (e.g. `pending_join_requests`) | Robust across deployments/restarts and multi-node; defined TTL cleanup (e.g. Oban cron); audit-friendly. | Persists PII before confirmation → stricter DSGVO/retention. |
| **B ETS with TTL** | No DB migration; PII gone on restart (can be a plus). | Multi-node: confirm link can hit another node → token unknown. Deploy/restart invalidates pending confirmations → support load. Acceptable for single-node MVP only. |
| **C Stateless token** (encrypted + signed payload, e.g. `Plug.Crypto.MessageEncryptor` / Phoenix.Token) | No PII persisted before confirmation (strong for DSGVO); multi-node ok (shared secret); no cleanup job. | Token size (custom fields!) and key rotation must be considered; payload must be re-validated on confirm (do this anyway). |
**Recommendation:** For strict "minimal PII retention", **Option C** is often the best tradeoff: persist only after confirmation; pending data is carried securely in the token. For very large forms, consider DB (Option A) later. If multi-node and auditability are top priorities from day one, choose Option A and define retention/cleanup clearly.
#### 2.3.2 JoinRequest: Data Model and Schema
- **Payload vs typed columns:** If storing payload as `map` (e.g. jsonb), also store **email** as a dedicated field (index, search, dedup, audit). Add a **schema_version** (e.g. tied to `member_fields()` evolution) so future changes do not break existing records. **Logger hygiene:** Do not log the full payload; follow CODE_GUIDELINES on log sanitization. Alternative: important fields typed (email, first_name, last_name) and only e.g. custom_field_values as jsonb.
- **Status and audit:** Besides `submitted`, plan for `approved` / `rejected` with **approved_at**, **rejected_at**, **reviewed_by_user_id** for audit.
- **Idempotency:** The confirm link must not create two JoinRequests. Enforce via e.g. **unique_index on confirmation_token_hash** or a dedicated token entity; on duplicate confirm, return success (idempotent) and do not create a second record.
- **Abuse metadata:** Define whether stored data (e.g. IP hash) is **security telemetry** or **personally identifiable** (DSGVO). Prefer storing only hashed/aggregated values (e.g. /24 prefix hash or keyed-hash), not raw IP; document the classification and retention.
### 2.4 Security
- **Public path:** `/join` and the confirmation route must be public (unauthenticated access returns 200). **Route choice:** Either (1) add `/join` and `/join/confirm*` to the page-permission plugs `public_path?/1`, or (2) put the confirm route under **`/confirm_join/:token`** so that the existing whitelist (e.g. `String.starts_with?(path, "/confirm")`) already covers it. **Recommendation:** Variant 2 keeps the plug unchanged and aligns with existing confirmation behaviour; fewer special cases.
- **Abuse:** **Honeypot** (MVP) plus **rate limiting** (MVP). Phoenix/Elixir has standard-friendly options: e.g. **Hammer** with **Hammer.Plug** (ETS backend, no Redis required), easily scoped to the join flow (e.g. by IP). Use both from the start for public intake. Before or during implementation, verify the chosen rate-limit library (e.g. Hammer) for current version, Phoenix/Plug compatibility, and suitability (e.g. ETS vs. Redis in multi-node setups).
- **Data:** Minimal PII; no sensitive data on the public form; consider DSGVO and leak risk when extending fields. If abuse signals (e.g. IP hash, spam score) are stored: store only hashed or aggregated values (e.g. IP hash, not plain IP); ensure DSGVO/compliance when persisting any identifying or behavioural data.
- **Approval-only:** No automatic User/Member creation from the join form; approval (Step 2) or other trusted path creates identity.
- **Ash policies and actor:** JoinRequest must have **explicit public actions** that are allowed with `actor: nil` (e.g. dedicated `public_submit` / `confirm` actions). Model this via **policies** that permit these actions when actor is nil; do **not** pass `authorize?: false` unless the reason is documented and it is clear that this is not a privilege-escalation path.
- **No system-actor fallback:** The join and confirmation flow run without an authenticated user. When implementing backend actions (e.g. sending the confirmation email, creating the JoinRequest), do **not** use the system actor as a fallback for “missing actor”. Use an explicit unauthenticated/system context instead; never escalate privileges. See CODE_GUIDELINES §5.0.
### 2.5 Usability and UX
- **No JoinRequest until confirmed:** Communicate clearly: e.g. "We have sent you an email … Your request will only be submitted after you click the link." (Exact copy in implementation spec.)
- Clear heading and short copy (e.g. “Become a member / Submit request” and “What happens next”).
- Form only as simple as needed (conversion vs. data hunger).
- Success message: neutral, no promise of an account (“We will get in touch”).
- **Expired confirmation link:** If the user clicks after the token has expired, show a clear message (e.g. “This link has expired”) and instruct them to submit the form again. Specify exact copy and behaviour in the implementation spec.
- **Re-send confirmation link:** Out of scope for Prio 1. A minimal "Resend" reduces support load; if not implemented in Prio 1, **create a separate ticket immediately** for this improvement. See also §6 (Resend confirmation). Example UX: “Request new confirmation email” on the “Please confirm your email” or expired-link page.
- Accessibility and i18n: same standards as rest of the app (e.g. labels, errors, Gettext).
### 2.6 Admin Configurability: Join Form Settings
- **Placement:** Own section **“Onboarding / Join”** in global settings, **above** “Custom fields”, **below** “Vereinsdaten” (club data).
- **Join form enabled:** Checkbox (e.g. `join_form_enabled`). When set, the public `/join` page is active and the following config applies.
- **Field selection:** From **all existing** member fields (from `Mv.Constants.member_fields()`) and **custom fields**, the admin selects which fields appear on the join form. Stored as a list/set of field identifiers (no separate table); display in settings as a simple list, e.g. **badges with X to remove** (similar to the groups overview where members are added to a group: selected items as badges with remove). Adding fields: e.g. dropdown or modal to pick from remaining fields. Detailed UX for this settings subsection is to be specified in a **separate subtask** (no full table; keep overview clear).
- **Technically required fields:** The only field that must always be required for the join flow is **email** (needed for the confirmation link and for creating a Member later). All other fields can be optional or marked as required per admin choice; implementation should support a “required” flag per selected join-form field.
- **Other:** Which entry paths are enabled, approval workflow (who can approve) to be detailed in Step 2 and later specs.
---
## 3. Step 2: Vorstand Approval
- **Goal:** Board (Vorstand) can review join requests (e.g. list “submitted”) and approve or reject.
- **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. for invite-to-set-password or similar). This behaviour depends on the configured login policies (OIDC, password, etc.) and is **more complex**; it is explicitly noted as an **open topic for later** and should not block Prio 1 or the basic approval flow. Implementation concepts for “approval + User creation” will be detailed when that option is implemented.
- **Permissions:** Approval uses the existing permission set **normal_user** (e.g. role “Kassenwart”). No new permission set. The JoinRequest resource receives read and update (or dedicated approve/reject actions) for scope :all in normal_user, and the approval page (e.g. `/join_requests` or `/onboarding/join_requests`) is added to normal_users allowed pages. Users with normal_user can already create members; they can therefore approve join requests and create the resulting member. The organisation can assign the role that has normal_user to the person(s) who should perform approvals (e.g. Kassenwart or Vorstand, depending on configuration).
---
## 4. Future Entry Paths (Out of Scope Here)
- **Invite link (tokenized):** Unique link per invitee; submission or account creation tied to token; no public form for that link.
- **OIDC first-login (JIT):** First login via OIDC creates/links User and optionally Member from IdP data; no prior join form required for that path.
- Both must be design-ready (e.g. shared “onboarding request” or “intake” abstraction) so they can attach to the same approval or creation pipeline later.
---
## 5. Evaluation of the Proposed Concept Draft
**Adopted and reflected above:**
- **Naming:** Resource name **JoinRequest** (one resource, status + optional approval/rejection timestamps).
- **No User/Member from `/join`:** Only a JoinRequest; record is created **after** email confirmation. Keeps abuse surface and policy complexity low.
- **Dedicated resource and action:** New resource `JoinRequest` and public actions (e.g. one for “submit form” → store pending + send email; one for “confirm token” → create JoinRequest). Member/User domain unchanged.
- **Public path:** `/join` and confirmation route public; prefer `/confirm_join/:token` so existing whitelist covers it (see §2.4).
- **Minimal data:** Email is technically required; other fields from admin-configured join-form field set, with optional “required” per field.
- **Security:** Honeypot + rate limiting (e.g. Hammer.Plug) in MVP; email confirmation before creating JoinRequest.
- **Tests:** Unauthenticated GET `/join` → 200; confirm flow creates one JoinRequest; honeypot and rate limiting covered; public paths in plug test matrix.
**Refinements in this document:**
- Approval (Vorstand) as Step 2; approval outcome configurable; User creation after approval noted as open for later.
- Admin configurability: join form settings as own section (placement, field selection, required fields); detailed UX in a subtask.
- Three entry paths (public, invite, OIDC) and their place in the roadmap made explicit.
---
## 6. Decisions and Open Points
**Decided:**
- **Email confirmation:** JoinRequest is created **only after** the user clicks the confirmation link (double opt-in). Effort is acceptable; existing confirmation pattern in the app (AshAuthentication) can be reused.
- **Naming:** **JoinRequest**.
- **Approval outcome:** Admin-configurable. Default: approval creates Member only (no User). Optional “create User on approval” is possible but depends on login policies (OIDC, password, etc.) and is **left as an open topic for later** (to be specified when that option is implemented).
- **Rate limiting:** Plan for **honeypot + rate limiting** from the start. Phoenix/Elixir ecosystem offers ready options (e.g. **Hammer** with **Hammer.Plug**, ETS backend) that are easy to integrate.
- **Settings:** Own section “Onboarding / Join” in global settings (above custom fields, below club data). `join_form_enabled` plus field selection from all member fields and custom fields; display as list/badges with X to remove (UX reference: groups overview, add-members-to-group dialog). **Email** is the only technically required field; other required flags per field are configurable. Detailed UX for this subsection is to be specified in a **separate subtask**.
- **Approval permission:** The approval UI and actions (list join requests, approve, reject) are gated by the existing permission set **normal_user**. JoinRequest read/update (or approve/reject actions) and the approval page are added to normal_user in PermissionSets; no new permission set or role required. The role that carries normal_user (e.g. Kassenwart) is the one that can perform approvals; the organisation assigns that role to the appropriate person(s).
- **Pre-confirmation store:** Choose explicitly among DB table (A), ETS (B), or stateless token (C); see §2.3.1. Recommendation: Option C for minimal PII retention; Option A if multi-node/audit from day one.
- **Confirmation route:** Prefer **`/confirm_join/:token`** so existing public-path logic (e.g. `starts_with?(path, "/confirm")`) covers it without plug changes.
- **JoinRequest schema:** Email as dedicated field when using payload map; add schema_version for evolution; approved_at, rejected_at, reviewed_by_user_id for audit; idempotent confirm (unique constraint on token); abuse metadata classified (telemetry vs PII) and stored hashed (e.g. /24 or keyed-hash). See §2.3.2.
- **Resend confirmation:** If not in Prio 1, create a separate ticket immediately.
---
## 7. Definition of Done (Prio 1)
- Public `/join` page (and confirmation route) reachable without login (public paths configured and tested).
- Flow: form submit → confirmation email → user clicks link → **then** one JoinRequest is created in status “submitted”; no User or Member is created before or by this flow.
- Anti-abuse: honeypot and rate limiting (e.g. Hammer.Plug) implemented and tested.
- Page-permission and routing tests updated (including public-path coverage).
- Concept and decisions (§6) documented for use in implementation specs.
---
## 8. Implementation Plan (Subtasks)
The feature is split into a small number of well-bounded subtasks. **Resend confirmation** remains a separate ticket (see §2.5, §6).
### Prio 1 Public Join (4 subtasks)
#### 1. JoinRequest resource and public policies ✅
- **Scope:** Ash resource `JoinRequest` per §2.3.2 (email, payload/schema_version, status, submitted_at, approved_at, rejected_at, reviewed_by_user_id, source, optional abuse metadata); migration; idempotency key (e.g. unique_index on confirmation_token_hash).
- **Policies:** Explicit public actions (e.g. `confirm`) allowed with `actor: nil`; no system-actor fallback, no undocumented `authorize?: false`.
- **Boundary:** No UI, no emails, no pre-confirmation logic only resource, persistence, and “creatable with nil actor”.
- **Done:** Resource and migration in place; tests in `test/mv/membership/join_request_test.exs` for create/read with `actor: nil` and for idempotency (same token twice → no second record).
#### 2. Pre-confirmation store and confirm flow
- **Scope:** Decide store (A/B/C per §2.3.1). Implement: form submit → create token (stateless) or store in ETS/DB → send confirmation email; route **`/confirm_join/:token`** → verify token → create exactly one JoinRequest (idempotent). Email sender (reuse pattern from AshAuthentication).
- **Boundary:** No join-form UI, no admin settings only “pending data + token” and “click token → create JoinRequest”. Depends on JoinRequest resource (subtask 1).
- **Done:** After submit, no JoinRequest; after link click, exactly one; double click idempotent; expired token shows clear message. Tests for these cases.
#### 3. Admin: Join form settings
- **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 can be a small sub-subtask).
- **Boundary:** No public form, no confirm logic 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
- **Scope:** Route **`/join`** (public), LiveView (or controller + form). Form only for fields from allowlist (subtask 3); copy per §2.5 (including “no JoinRequest until confirmed”). **Honeypot** and **rate limiting** (e.g. Hammer.Plug) on join/submit. After submit: show “We have sent you an email …”. Expired-link page: clear message + “submit form again”. Public paths in page-permission plug (confirm that `/confirm_join` is already covered by existing rule if using recommended route).
- **Boundary:** No approval UI, no User/Member creation only public page, form, anti-abuse, and wiring to confirm flow (subtask 2).
- **Done:** Unauthenticated GET `/join` → 200; submit → no JoinRequest created, email triggered; link click uses subtask 2; honeypot and rate limit tested; public-path tests updated.
### Order and dependencies
- **1 → 2:** Confirm flow creates JoinRequests (resource must exist).
- **3 before or in parallel with 4:** Form reads allowlist from settings; for MVP, subtask 4 can use a default allowlist and 3 can follow shortly after.
- **Recommended order:** **1****2****3****4** (or 3 in parallel with 2 if two people work on it).
### Step 2 Approval (1 subtask, later)
#### 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).
- **Boundary:** Separate ticket; builds on JoinRequest and existing Member creation.
---
## 9. References
- `docs/roles-and-permissions-architecture.md` Permission sets, roles, page permissions.
- `docs/page-permission-route-coverage.md` Public paths, plug behaviour, tests.
- `lib/mv_web/plugs/check_page_permission.ex` Public path list and redirect behaviour.
- `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; ETS backend does not require Redis.
- Issue #308 Original feature/planning context.

View file

@ -0,0 +1,143 @@
defmodule Mv.Membership.JoinRequest do
@moduledoc """
Ash resource for public join requests (onboarding flow).
Created only after email confirmation (double opt-in). Per concept §2.3.2:
- email (dedicated field), payload, schema_version, status, submitted_at, source
- approved_at, rejected_at, reviewed_by_user_id for audit (Step 2)
- confirmation_token_hash for idempotency (unique constraint)
"""
use Ash.Resource,
domain: Mv.Membership,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]
alias Ash.Policy.Check.Builtins, as: AshBuiltins
postgres do
table "join_requests"
repo Mv.Repo
end
actions do
defaults [:destroy]
# Admin: list and get by id (used with HasPermission)
read :admin_read do
description "List and get JoinRequests; requires permission (e.g. admin / normal_user)"
primary? true
end
create :create do
primary? true
accept [
:email,
:confirmation_token_hash,
:status,
:submitted_at,
:source,
:schema_version,
:payload,
:approved_at,
:rejected_at,
:reviewed_by_user_id
]
end
create :confirm do
description "Public action: create JoinRequest after confirmation link click (actor: nil)"
accept [:email, :confirmation_token_hash, :payload]
change Mv.Membership.JoinRequest.Changes.SetConfirmServerMetadata
end
update :update do
accept [:status, :approved_at, :rejected_at, :reviewed_by_user_id]
require_atomic? false
end
end
policies do
policy action(:confirm) do
description "Allow public confirmation (actor nil) for join flow"
authorize_if AshBuiltins.actor_absent()
end
policy action(:admin_read) do
description "List/get JoinRequests only with permission (admin, later normal_user)"
authorize_if Mv.Authorization.Checks.HasPermission
end
policy action(:create) do
description "Generic create only for authorized users; public uses :confirm"
authorize_if Mv.Authorization.Checks.HasPermission
end
policy action_type([:update, :destroy]) do
authorize_if Mv.Authorization.Checks.HasPermission
end
end
attributes do
uuid_v7_primary_key :id
attribute :email, :string do
allow_nil? false
public? true
end
attribute :confirmation_token_hash, :string do
allow_nil? false
public? true
end
attribute :status, :string do
allow_nil? false
public? true
default "submitted"
end
attribute :submitted_at, :utc_datetime_usec do
allow_nil? false
public? true
end
attribute :source, :string do
allow_nil? false
public? true
end
attribute :schema_version, :integer do
allow_nil? false
public? true
end
attribute :payload, :map do
allow_nil? true
public? true
default %{}
end
attribute :approved_at, :utc_datetime_usec do
allow_nil? true
public? true
end
attribute :rejected_at, :utc_datetime_usec do
allow_nil? true
public? true
end
attribute :reviewed_by_user_id, :uuid do
allow_nil? true
public? true
end
timestamps()
end
identities do
identity :unique_confirmation_token_hash, [:confirmation_token_hash]
end
end

View file

@ -0,0 +1,18 @@
defmodule Mv.Membership.JoinRequest.Changes.SetConfirmServerMetadata do
@moduledoc """
Ash Change that sets server-side metadata for the public :confirm action.
Client may only send :email, :confirmation_token_hash, :payload (concept §2.3.2).
This change sets: status, submitted_at, source, schema_version so they cannot be forged.
"""
use Ash.Resource.Change
@impl true
def change(changeset, _opts, _context) do
changeset
|> Ash.Changeset.force_change_attribute(:status, "submitted")
|> Ash.Changeset.force_change_attribute(:submitted_at, DateTime.utc_now())
|> Ash.Changeset.force_change_attribute(:source, "public_join")
|> Ash.Changeset.force_change_attribute(:schema_version, 1)
end
end

View file

@ -78,6 +78,47 @@ defmodule Mv.Membership do
define :list_member_groups, action: :read define :list_member_groups, action: :read
define :destroy_member_group, action: :destroy define :destroy_member_group, action: :destroy
end end
resource Mv.Membership.JoinRequest do
define :list_join_requests, action: :admin_read
define :get_join_request, action: :admin_read, get_by: [:id]
define :update_join_request, action: :update
define :destroy_join_request, action: :destroy
end
end
# Idempotent confirm: duplicate token hits unique constraint -> return {:ok, nil} (no public read)
@doc """
Creates a JoinRequest after confirmation link click (public action with actor: nil).
Idempotent: if a JoinRequest with the same `confirmation_token_hash` already exists,
returns `{:ok, nil}` (no record returned; no public read for security).
"""
def confirm_join_request(attrs, opts \\ []) do
case do_confirm_join_request(attrs, opts) do
{:ok, request} ->
{:ok, request}
{:error, %Ash.Error.Invalid{errors: errors}} = error ->
if unique_confirmation_token_violation?(errors), do: {:ok, nil}, else: error
other ->
other
end
end
defp do_confirm_join_request(attrs, opts) do
Mv.Membership.JoinRequest
|> Ash.Changeset.for_create(:confirm, attrs)
|> Ash.create(Keyword.put(opts, :domain, __MODULE__))
end
defp unique_confirmation_token_violation?(errors) do
Enum.any?(errors, fn err ->
Map.get(err, :field) == :confirmation_token_hash or
((pv = Map.get(err, :private_vars)) &&
(is_list(pv) and Keyword.get(pv, :constraint_type) == :unique))
end)
end end
# Singleton pattern: Get the single settings record # Singleton pattern: Get the single settings record

View file

@ -269,6 +269,7 @@ defmodule Mv.Authorization.PermissionSets do
perm_all("Role") ++ perm_all("Role") ++
perm_all("Group") ++ perm_all("Group") ++
member_group_perms ++ member_group_perms ++
perm_all("JoinRequest") ++
perm_all("MembershipFeeType") ++ perm_all("MembershipFeeType") ++
perm_all("MembershipFeeCycle"), perm_all("MembershipFeeCycle"),
pages: [ pages: [

View file

@ -2608,18 +2608,3 @@ msgstr "Import"
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Value type cannot be changed after creation" msgid "Value type cannot be changed after creation"
msgstr "Der Wertetyp kann nach dem Erstellen nicht mehr geändert werden." msgstr "Der Wertetyp kann nach dem Erstellen nicht mehr geändert werden."
#~ #: lib/mv_web/live/import_export_live.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Export Members (CSV)"
#~ msgstr "Mitglieder exportieren (CSV)"
#~ #: lib/mv_web/live/import_export_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Export functionality will be available in a future release."
#~ msgstr "Export-Funktionalität ist im nächsten release verfügbar."
#~ #: lib/mv_web/live/import_export_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Import members from CSV files or export member data."
#~ msgstr "Importiere Mitglieder aus CSV-Dateien oder exportiere Mitgliederdaten."

View file

@ -2609,18 +2609,3 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Value type cannot be changed after creation" msgid "Value type cannot be changed after creation"
msgstr "" msgstr ""
#~ #: lib/mv_web/live/import_export_live.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Export Members (CSV)"
#~ msgstr ""
#~ #: lib/mv_web/live/import_export_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Export functionality will be available in a future release."
#~ msgstr ""
#~ #: lib/mv_web/live/import_export_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Import members from CSV files or export member data."
#~ msgstr ""

View file

@ -0,0 +1,42 @@
defmodule Mv.Repo.Migrations.AddJoinRequests do
@moduledoc """
Adds join_requests table for public join flow (onboarding concept §2.3.2).
"""
use Ecto.Migration
def up do
create table(:join_requests, primary_key: false) do
add :id, :uuid, null: false, default: fragment("uuid_generate_v7()"), primary_key: true
add :email, :string, null: false
add :confirmation_token_hash, :string, null: false
add :status, :string, null: false
add :submitted_at, :utc_datetime_usec, null: false
add :source, :string, null: false
add :schema_version, :bigint, null: false
add :payload, :map, null: true
add :approved_at, :utc_datetime_usec, null: true
add :rejected_at, :utc_datetime_usec, null: true
add :reviewed_by_user_id, :uuid, null: true
add :inserted_at, :utc_datetime_usec,
null: false,
default: fragment("(now() AT TIME ZONE 'utc')")
add :updated_at, :utc_datetime_usec,
null: false,
default: fragment("(now() AT TIME ZONE 'utc')")
end
create unique_index(:join_requests, [:confirmation_token_hash],
name: "join_requests_unique_confirmation_token_hash_index"
)
end
def down do
drop_if_exists unique_index(:join_requests, [:confirmation_token_hash],
name: "join_requests_unique_confirmation_token_hash_index"
)
drop table(:join_requests)
end
end

View file

@ -0,0 +1,18 @@
defmodule Mv.Repo.Migrations.AlterJoinRequestsSchemaVersionToInteger do
@moduledoc """
Aligns schema_version with Ash attribute type :integer (concept review).
"""
use Ecto.Migration
def up do
alter table(:join_requests) do
modify :schema_version, :integer, null: false
end
end
def down do
alter table(:join_requests) do
modify :schema_version, :bigint, null: false
end
end
end

View file

@ -0,0 +1,118 @@
defmodule Mv.Membership.JoinRequestTest do
@moduledoc """
Tests for JoinRequest resource and public policies (Subtask 1: onboarding join concept).
Covers: public create/read with actor nil, idempotency of confirm (confirmation_token_hash),
and minimal required attributes. No framework behaviour is tested; only our policies and constraints.
"""
use Mv.DataCase, async: false
alias Mv.Helpers.SystemActor
alias Mv.Membership
alias Mv.Membership.JoinRequest
require Ash.Query
# Client-only attributes for :confirm (server sets status, submitted_at, source, schema_version)
defp valid_confirm_attrs(opts \\ []) do
token =
Keyword.get(opts, :confirmation_token_hash, "hash_#{System.unique_integer([:positive])}")
[
email: "join_#{System.unique_integer([:positive])}@example.com",
confirmation_token_hash: token,
payload: %{}
]
|> Enum.into(%{})
end
describe "Public policies (actor: nil)" do
test "confirm with actor nil succeeds" do
attrs = valid_confirm_attrs()
assert {:ok, %JoinRequest{} = request} =
Membership.confirm_join_request(attrs, actor: nil)
assert request.email == attrs.email
assert request.status == "submitted"
assert request.source == "public_join"
end
test "no public read: actor nil cannot read JoinRequest (by id or list)" do
attrs = valid_confirm_attrs()
{:ok, created} = Membership.confirm_join_request(attrs, actor: nil)
get_result = Ash.get(JoinRequest, created.id, actor: nil, domain: Mv.Membership)
assert match?({:error, %Ash.Error.Forbidden{}}, get_result) or
match?(
{:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{}]}},
get_result
)
list_result = JoinRequest |> Ash.read(actor: nil, domain: Mv.Membership)
assert match?({:error, %Ash.Error.Forbidden{}}, list_result) or
match?({:error, %Ash.Error.Invalid{}}, list_result) or
list_result == {:ok, []},
"actor nil must not see any JoinRequests: got #{inspect(list_result)}"
end
test "generic create with actor nil is forbidden" do
# Use full attrs required by :create so the only failure is policy, not validation
attrs =
valid_confirm_attrs()
|> Map.merge(%{
status: "submitted",
submitted_at: DateTime.utc_now(),
source: "public_join",
schema_version: 1
})
assert {:error, %Ash.Error.Forbidden{errors: [%Ash.Error.Forbidden.Policy{}]}} =
JoinRequest
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create(actor: nil, domain: Mv.Membership)
end
end
describe "Idempotency (confirmation_token_hash)" do
test "second create with same confirmation_token_hash does not create duplicate" do
system_actor = SystemActor.get_system_actor()
token = "idempotent_token_#{System.unique_integer([:positive])}"
attrs1 = valid_confirm_attrs(confirmation_token_hash: token)
attrs2 = valid_confirm_attrs(confirmation_token_hash: token)
attrs2 = %{attrs2 | email: "other_#{System.unique_integer([:positive])}@example.com"}
assert {:ok, _first} = Membership.confirm_join_request(attrs1, actor: nil)
# Second call with same token: idempotent return {:ok, nil} (no public read)
assert {:ok, nil} = Membership.confirm_join_request(attrs2, actor: nil)
# Count via allowed admin read (no authorize?: false)
assert {:ok, list} = Membership.list_join_requests(actor: system_actor)
count = Enum.count(list, &(&1.confirmation_token_hash == token))
assert count == 1,
"expected exactly one JoinRequest with this confirmation_token_hash, got #{count}"
end
end
describe "Resource and validations" do
test "create with minimal required attributes succeeds" do
attrs = valid_confirm_attrs()
assert {:ok, %JoinRequest{}} = Membership.confirm_join_request(attrs, actor: nil)
end
test "email is required" do
attrs = valid_confirm_attrs() |> Map.delete(:email)
assert {:error, %Ash.Error.Invalid{errors: errors}} =
Membership.confirm_join_request(attrs, actor: nil)
assert Enum.any?(errors, fn e -> Map.get(e, :field) == :email end),
"expected an error for field :email, got: #{inspect(errors)}"
end
end
end