docs(membership): condense membership, onboarding and import docs and align with the code

This commit is contained in:
Moritz 2026-06-15 21:53:36 +02:00
parent 8d783276d0
commit 5d8f173529
4 changed files with 436 additions and 1904 deletions

View file

@ -1,20 +1,16 @@
# Onboarding & Join High-Level Concept
**Status:** Draft for design decisions and implementation specs. **Prio 1 (Subtasks 14) implemented.**
**Scope:** Prio 1 = public Join form; Step 2 = Vorstand approval. Invite-Link and OIDC JIT are out of scope and documented only as future entry paths.
**Status:** Prio 1 (Subtasks 14) and Step 2 (Vorstand approval, Subtask 5) implemented. The Invite-Link / OIDC-JIT join entry paths (§4) are designed here but **not yet implemented**.
**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.
- **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 (which fields, approval required) shall be admin-configurable; MVP may start with sensible defaults.
- **Approval:** a Vorstand (board) approval step is the direct follow-up (Step 2) after the public Join; the data model and flow support it.
---
@ -22,168 +18,137 @@
### 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**: the JoinRequest record is **created in the database on form submit** in status `pending_confirmation`, then **updated to** `submitted` after the user clicks the confirmation link.
- 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.
- **Elixir/Phoenix/Ash standard:** Data is persisted in the database from the start (one Ash resource, status-driven flow). No ETS or stateless token for pre-confirmation storage; confirm flow only updates the existing record.
- **Public** page `/join`: no login; anyone can open and submit.
- The result is **not** a User or Member but a **JoinRequest** record, created in the DB on form submit in status `pending_confirmation`, then updated to `submitted` after the user clicks the confirmation link.
- This keeps public intake (abuse-prone) separate from identity/account creation, and leaves existing policies (UserMember linking, admin-only link) untouched until a defined promotion flow (after approval) creates User/Member.
- **Standard:** data is persisted in the DB from the start (one Ash resource, status-driven). No ETS or stateless token for pre-confirmation storage; the confirm flow only updates the existing record.
### 2.2 User Flow (Prio 1)
### 2.2 User Flow
1. Unauthenticated user opens `/join`.
2. Short explanation + form (what happens next: "We will review … you will hear from us").
3. **Submit**A **JoinRequest is created** in the database with status `pending_confirmation`; confirmation email is sent; user sees: "We have saved your details. To complete your request, please click the link we sent to your email."
4. **User clicks confirmation link**The existing JoinRequest is **updated** to status `submitted` (`submitted_at` set, confirmation token invalidated); user sees: "Thank you, we have received your request."
2. Short explanation + form ("We will review … you will hear from us").
3. **Submit** → JoinRequest created with status `pending_confirmation`; confirmation email sent; user sees "We have saved your details. To complete your request, please click the link we sent to your email."
4. **User clicks confirmation link**existing JoinRequest updated to `submitted` (`submitted_at` set, confirmation token invalidated); user sees "Thank you, we have received your request."
**Rationale (double opt-in with DB-first):** Email confirmation remains best practice (we only treat the request as "submitted" after the link is clicked). The record exists in the DB from submit time so we use standard Phoenix/Ash persistence, multi-node safety, and a simple status transition (`pending_confirmation``submitted`) on confirm. This aligns with patterns like AshAuthentication (resource exists before confirm; confirm updates state).
**Out of scope for Prio 1:** Approval UI, account creation, OIDC, invite links.
**Rationale (double opt-in, DB-first):** email confirmation stays best practice (treated as "submitted" only after the click); the record exists in the DB from submit time, so we get standard Phoenix/Ash persistence, multi-node safety, and a simple `pending_confirmation → submitted` transition. Aligns with AshAuthentication (resource exists before confirm; confirm updates state).
### 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 is enforced in the LiveView (`build_submit_attrs`) and in the resource via **`JoinRequest.Changes.FilterFormDataByAllowlist`** so that even direct API/submit_join_request calls only persist allowlisted form_data keys.
- **On form submit:** **Create** a JoinRequest with status `pending_confirmation`, store confirmation token **hash** in the DB (raw token only in the email link), set `confirmation_token_expires_at` (e.g. 24h), store all allowlisted form data (see §2.3.2), then send confirmation email.
- **On confirmation link click:** **Update** the JoinRequest (find by token hash): set status to `submitted`, set `submitted_at`, clear/invalidate token fields. If the record is already `submitted`, return success without changing it (idempotent).
- **No creation** of Member or User in Prio 1; promotion to Member/User happens in a later step (e.g. after approval).
- **Input:** only data explicitly allowed for the public form; field set is admin-configured (§2.6). No internal/sensitive fields. **Server-side allowlist:** accepted fields are enforced both in the LiveView (`build_submit_attrs`) and in the resource via **`JoinRequest.Changes.FilterFormDataByAllowlist`**, so even direct API / `submit_join_request` calls persist only allowlisted `form_data` keys.
- **On submit:** create a JoinRequest (status `pending_confirmation`), store confirmation token **hash** in the DB (raw token only in the email link), set `confirmation_token_expires_at` (e.g. 24h), store all allowlisted form data, then send the confirmation email.
- **On confirm link click:** find by token hash, set status `submitted`, set `submitted_at`, clear/invalidate token fields. If already `submitted`, return success without changing it (idempotent).
- **No Member/User creation** in Prio 1; promotion happens later (after approval).
#### 2.3.1 Pre-Confirmation Store (Decided)
**Decision:** Store in the **database** only. Use the **same** JoinRequest resource and table from the start.
**Decision:** store in the **database** only, using the **same** JoinRequest resource and table throughout. On submit, create one row (`pending_confirmation`, token hash, expiry); on confirm, update that row to `submitted` — no second table, no ETS, no stateless token.
- On submit: **create** one JoinRequest row with status `pending_confirmation`, confirmation token **hash**, and expiry.
- On confirm: **update** that row to status `submitted` (no second table, no ETS, no stateless token).
- **Retention and cleanup:** JoinRequests that remain in `pending_confirmation` past the token expiry (e.g. 24 hours) are **hard-deleted** by a scheduled job (e.g. Oban cron). Retention period: **24 hours**; document in DSGVO/retention as needed.
- **Rationale:** Elixir/Phoenix/Ash standard is persistence in DB, one resource, status machine. Multi-node safe, restart safe, and cleanup is a standard cron task.
**Retention and cleanup:** JoinRequests still in `pending_confirmation` past the token expiry are **hard-deleted** by a scheduled job (Oban cron). Retention period: **24 hours**; document in DSGVO/retention as needed. Multi-node and restart safe; cleanup is a standard cron task.
#### 2.3.2 JoinRequest: Data Model and Schema
- **Status:** `pending_confirmation` (initial, after form submit) → `submitted` (after link click) → later `approved` / `rejected`. Include **approved_at**, **rejected_at**, **reviewed_by_user_id** for audit.
- **Confirmation:** Store **confirmation_token_hash** (not the raw token); **confirmation_token_expires_at**; optional **confirmation_sent_at**. Raw token appears only in the email link; on confirm, hash the incoming token and find the record by hash.
- **Payload vs typed columns (recommendation):**
- **Typed columns** for **email** (required, dedicated field for index, search, dedup, audit) and for **first_name** and **last_name** (optional). These align with `Mv.Constants.member_fields()` and with the existing Member resource; they support approval-list display and straightforward promotion to Member without parsing JSON.
- **Remaining form data** (other member fields + custom field values) in a **jsonb** attribute (e.g. `form_data`) plus a **schema_version** (e.g. tied to join-form or member_fields evolution) so future changes do not break existing records.
- **What it depends on:** (1) Whether the join form field set is fixed or often extended if fixed, more typed columns are feasible; if very dynamic, keeping the rest in jsonb avoids migrations. (2) Whether the approval UI or reporting needs to filter/sort by other fields (e.g. city) if yes, consider adding those as typed columns later. For MVP, email + first_name + last_name typed and rest in jsonb is a good balance with the current codebase (Member has typed attributes; export/import use allowlists of field names).
- **Logger hygiene:** Do not log the full payload/form_data; follow CODE_GUIDELINES on log sanitization.
- **Idempotency:** Confirm action finds the JoinRequest by token hash; if status is already `submitted`, return success without updating. Optionally enforce **unique_index on confirmation_token_hash** so the same token cannot apply to more than one record.
- **Abuse metadata:** If stored (e.g. IP hash), classify as **security telemetry** or **personally identifiable** (DSGVO). Prefer hashed/aggregated values only (e.g. /24 prefix hash or keyed-hash), not raw IP; document classification and retention. Out of scope for Prio 1 unless explicitly added.
- **Status:** `pending_confirmation` (initial) → `submitted` (after link click) → later `approved` / `rejected`. Audit: **approved_at**, **rejected_at**, **reviewed_by_user_id**.
- **Confirmation:** store **confirmation_token_hash** (not the raw token); **confirmation_token_expires_at**; optional **confirmation_sent_at**. The raw token appears only in the email link; on confirm, hash the incoming token and find the record by hash.
- **Payload vs typed columns:** **typed columns** for **email** (required — dedicated field for index, search, dedup, audit) and **first_name** / **last_name** (optional); these align with `Mv.Constants.member_fields()` and the Member resource, supporting approval-list display and straightforward promotion without parsing JSON. **Remaining form data** (other member fields + custom field values) goes in a **jsonb** attribute (`form_data`) plus a **schema_version** so future changes don't break existing records.
- *Depends on:* (1) whether the join-form field set is fixed (more typed columns feasible) or dynamic (keep rest in jsonb to avoid migrations); (2) whether approval UI/reporting needs to filter/sort by other fields (e.g. city) — if so, add typed columns later. For MVP, email + first_name + last_name typed and the rest in jsonb balances well with the current codebase.
- **Logger hygiene:** do not log the full payload/`form_data`; follow CODE_GUIDELINES on log sanitization.
- **Idempotency:** confirm finds the JoinRequest by token hash; if already `submitted`, return success without updating. Optionally enforce a **unique_index on confirmation_token_hash**.
- **Abuse metadata:** if stored (e.g. IP hash), classify as security telemetry or PII (DSGVO). Prefer hashed/aggregated values only (e.g. /24 prefix hash or keyed-hash), not raw IP; document classification and retention. Out of scope for Prio 1 unless explicitly added.
### 2.4 Security
- **Public paths:** `/join` and the confirmation route must be public (unauthenticated access returns 200).
- **Explicit public path for `/join`:** Add **`/join`** (and if needed `/join/*`) to the page-permission plugs **`public_path?/1`** so that the join page is reachable without login. Do not rely on the confirm path alone.
- **Confirmation route:** Use **`/confirm_join/:token`** so that the existing whitelist (e.g. `String.starts_with?(path, "/confirm")`) already covers it; no extra plug change for confirm.
- **Abuse:** **Honeypot** (MVP) plus **rate limiting** (MVP). Use Phoenix/Elixir standard options (e.g. **Hammer** with **Hammer.Plug**, ETS backend), scoped to the join flow (e.g. by IP). Client IP for rate limiting: prefer **X-Forwarded-For** / **X-Real-IP** when behind a reverse proxy (see Endpoint `connect_info: [:x_headers]` and `JoinLive.client_ip_from_socket/1`); fallback to peer_data with correct IPv4/IPv6 string via `:inet.ntoa/1`. Verify library version and multi-node behaviour before or during implementation.
- **Data:** Minimal PII; no sensitive data on the public form; consider DSGVO when extending. If abuse signals are stored: only hashed or aggregated values; document classification and retention.
- **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 has **explicit public actions** allowed with `actor: nil` (e.g. `submit` for create, `confirm` for update). Model via **policies** that permit these actions when actor is nil; do **not** use `authorize?: false` unless documented and clearly not a privilege-escalation path.
- **No system-actor fallback:** Join and confirmation run without an authenticated user. Do **not** use the system actor as a fallback for "missing actor". Use explicit unauthenticated context; see CODE_GUIDELINES §5.0.
- **Explicit public path for `/join`:** add **`/join`** (and if needed `/join/*`) to the page-permission plug's **`public_path?/1`**; do not rely on the confirm path alone.
- **Confirmation route:** use **`/confirm_join/:token`** so the existing whitelist (e.g. `String.starts_with?(path, "/confirm")`) already covers it no extra plug change for confirm.
- **Abuse:** **honeypot** + **rate limiting** in MVP (e.g. **Hammer** with **Hammer.Plug**, ETS backend), scoped to the join flow (e.g. by IP). Client IP: prefer **X-Forwarded-For** / **X-Real-IP** behind a reverse proxy (Endpoint `connect_info: [:x_headers]`, `JoinLive.client_ip_from_socket/1`); fallback to peer_data with correct IPv4/IPv6 string via `:inet.ntoa/1`. Verify library version and multi-node behaviour.
- **Data:** minimal PII; no sensitive data on the public form; consider DSGVO when extending. Stored abuse signals: only hashed/aggregated, documented.
- **Approval-only:** no automatic User/Member creation from the join form; approval (Step 2) or another trusted path creates identity.
- **Ash policies and actor:** JoinRequest has **explicit public actions** allowed with `actor: nil` (`submit` for create, `confirm` for update). Model via **policies** that permit these actions when actor is nil; do **not** use `authorize?: false` unless documented and clearly not a privilege-escalation path.
- **No system-actor fallback:** join and confirmation run without an authenticated user. Do **not** use the system actor as a fallback for a "missing actor"; use an explicit unauthenticated context. See CODE_GUIDELINES §5.0 and `lib/mv/authorization/checks/actor_is_nil.ex`.
### 2.5 Usability and UX
- **After submit:** Communicate clearly: e.g. "We have saved your details. To complete your request, please click the link we sent to your email." (Exact copy in implementation spec.)
- Clear heading and short copy (e.g. "Become a member / Submit request" and "What happens next").
- **After submit:** "We have saved your details. To complete your request, please click the link we sent to your email."
- Clear heading + short copy ("Become a member / Submit request", "What happens next").
- Form only as simple as needed (conversion vs. data hunger).
- Success message after confirm: 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. If not implemented in Prio 1, **create a separate ticket immediately**. 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 (labels, errors, Gettext).
- Confirm success message: neutral, no promise of an account ("We will get in touch").
- **Expired confirmation link:** clear message ("This link has expired") + instruction to submit the form again. Exact copy in the implementation spec.
- **Re-send confirmation link:** out of scope for Prio 1; if not implemented, **create a separate ticket immediately**. Example UX: "Request new confirmation email" on the confirm/expired page.
- Accessibility and i18n: same standards as the rest of the app (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.
- **Copyable join link:** When the join form is enabled, a copyable full URL to the `/join` page is shown below the checkbox (above the field list), with a short hint so admins can share it with applicants.
- **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). Adding fields: e.g. dropdown or modal to pick from remaining fields. Detailed UX for this subsection is to be specified in a **separate subtask**.
- **Technically required fields:** The only field that must always be required for the join flow is **email**. 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.
- **Placement:** own section **"Onboarding / Join"** in global settings, **above** "Custom fields", **below** "Vereinsdaten".
- **Join form enabled:** checkbox (`join_form_enabled`); when set, the public `/join` page is active and the config below applies.
- **Copyable join link:** when enabled, a copyable full URL to `/join` is shown below the checkbox (above the field list), with a short hint for sharing with applicants.
- **Field selection:** from **all existing** member fields (`Mv.Constants.member_fields()`) and **custom fields**, the admin picks which appear on the join form. Stored as a list/set of field identifiers (no separate table); displayed as **badges with X to remove** (like the groups overview), added via dropdown/modal. Detailed UX to be specified in a separate subtask.
- **Technically required fields:** only **email** must always be required. All others can be optional or marked required per admin choice; support a "required" flag per selected field.
- **Other:** which entry paths are enabled, approval workflow (who can approve) — detailed in Step 2 and later specs.
---
## 3. Step 2: Vorstand Approval
## 3. Step 2: Vorstand Approval (implemented)
- **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 **`/join_requests`** (and **`/join_requests/:id`** for detail) are added to normal_users allowed pages.
- **Goal:** the board can review join requests (e.g. list status "submitted") and approve or reject.
- **Route:** **`/join_requests`** (list) and **`/join_requests/:id`** (detail), defined in `MvWeb.Router``MvWeb.JoinRequestLive.Index` / `.Show`. Full spec in §3.1.
- **Outcome of approval:** approval creates a **Member only** (no User; an admin can link a User later). The optional "also create a User on approval" variant is **not yet implemented**.
- **Permissions:** approval uses the existing **normal_user** permission set (e.g. role "Kassenwart"). In `Mv.Authorization.PermissionSets`, normal_user has JoinRequest read + update for scope :all, and `/join_requests` and `/join_requests/:id` are in its allowed pages.
### 3.1 Step 2 Approval (detail)
### 3.1 Step 2 Approval (detail) — implemented in Subtask 5
Implementation spec for Subtask 5.
**Route and pages:**
#### Route and pages
- **List `/join_requests`:** filter by status (default/primary view: `submitted`); optional view for "all" or "approved/rejected" for audit.
- **Detail `/join_requests/:id`:** two blocks — (1) **Applicant data**: all form fields (typed + `form_data`) merged and shown in join-form order; (2) **Status and review**: submitted_at, status, and when decided approved_at/rejected_at + reviewed by. Approve / Reject actions when status is `submitted`.
- **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. **Two blocks:** (1) **Applicant data** all form fields (typed + `form_data`) merged and shown in join-form order; (2) **Status and review** submitted_at, status, and when decided: approved_at/rejected_at, reviewed by. Actions Approve / Reject when status is `submitted`.
**Backend (`Mv.Membership.JoinRequest`) — actions (authenticated only):**
#### Backend (JoinRequest)
- **`approve`** (update, change `JoinRequest.Changes.ApproveRequest`): allowed only when status is `submitted`. Sets `approved`, `approved_at`, `reviewed_by_user_id` / `reviewed_by_display` (actor). Promotion to Member is driven by the domain function (see below), not the change.
- **`reject`** (update, change `JoinRequest.Changes.RejectRequest`): allowed only when status is `submitted`. Sets `rejected`, `rejected_at`, `reviewed_by_user_id`. No reason field in MVP.
- **Policies:** `approve` and `reject` are each permitted via **`HasPermission`**; the read policy uses **`HasJoinRequestAccess`** (a SimpleCheck) so list/detail can load data. Not allowed for `actor: nil`.
- **Domain (`Mv.Membership`):** `list_join_requests/1` (filter by status, with actor), `approve_join_request/2` (id, actor), `reject_join_request/2` (id, actor).
- **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:**
#### Promotion: JoinRequest → Member
- **When:** on successful `approve` only (status was `submitted`).
- **Mapping:** typed fields **email**, **first_name**, **last_name** → Member attributes. **form_data** keys matching `Mv.Constants.member_fields()` (string form) → Member attributes; keys that are custom field IDs (UUID) → **CustomFieldValue** records linked to the new Member.
- **Defaults:** `join_date` = today. `membership_fee_type_id` is not set here; the Member `create_member` action applies the default fee type from settings (see `Mv.Membership.Member.Changes.SetDefaultMembershipFeeType`).
- **Implementation:** the domain function `Mv.Membership.approve_join_request/2` calls the private `promote_to_member/2`, which builds member attributes + custom_field_values and calls Member `create_member` with the reviewer as actor. No User created in MVP.
- **Atomicity:** the approve flow (get → update to approved → promote to Member) runs inside **`Ash.transact(JoinRequest, fn -> ... end)`**, so if Member creation fails (validation, unique constraint) the JoinRequest status rolls back.
- **Idempotency:** `ApproveRequest` only transitions from `submitted`; a repeated approve on an already-`approved` request is rejected with a status error, so no duplicate Member is created.
- **When:** On successful `approve` only (status was `submitted`).
- **Mapping:**
- JoinRequest typed fields → Member: **email**, **first_name**, **last_name** copied to Member attributes.
- **form_data** (jsonb): keys that match `Mv.Constants.member_fields()` (atom names or string keys) → corresponding Member attributes. Keys that are custom field IDs (UUID format) → create **CustomFieldValue** records linked to the new Member.
- **Defaults:** e.g. `join_date` = today if not in form_data; `membership_fee_type_id` = default from settings (or first available) if not provided. Handle required Member validations (e.g. email already present from JoinRequest).
- **Implementation:** Prefer a single Ash change (e.g. `JoinRequest.Changes.PromoteToMember`) or a domain function that builds member attributes + custom_field_values from the approved JoinRequest and calls Member `create_member` (actor: reviewer or system actor as per CODE_GUIDELINES; document choice). No User created in MVP.
- **Atomicity:** The approve flow (get JoinRequest → update to approved → promote to Member) runs inside **`Ash.transact(JoinRequest, fn -> ... end)`** so that if Member creation fails (e.g. validation, unique constraint), the JoinRequest status is rolled back and remains consistent.
- **Idempotency:** If approve is called again by mistake (e.g. race), either reject transition when status is already `approved` or ensure Member creation is idempotent (e.g. do not create duplicate Member for same JoinRequest).
**Permission sets and routing:**
#### Permission sets and routing
- **PermissionSets (`Mv.Authorization.PermissionSets`, normal_user):** JoinRequest **read** :all and **update** :all; pages `/join_requests` and `/join_requests/:id`.
- **Router (`MvWeb.Router`):** live routes `/join_requests``JoinRequestLive.Index` and `/join_requests/:id``JoinRequestLive.Show`; entries recorded in **page-permission-route-coverage.md**; plug coverage so normal_user is allowed, read_only/own_data denied.
- **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):**
#### UI/UX (approval)
- **List:** table/card list with columns e.g. submitted_at, first_name, last_name, email, status; primary/default filter status = `submitted`; links to detail. Follow existing list patterns (Members/Groups): header, back link, CoreComponents table.
- **Detail:** all request data (typed + form_data rendered by field); buttons **Approve** (primary), **Reject** (secondary); reject in MVP has no reason field. Same accessibility/i18n standards.
- **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.
**Tests:** 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 + audit; approve-when-already-approved is no-op or error); page permission (normal_user can GET both routes; read_only/own_data cannot); optional LiveView smoke test.
---
## 4. Future Entry Paths (Out of Scope Here)
## 4. Future Entry Paths (Out of Scope Here, not yet implemented)
- **Invite link (tokenized):** Unique link per invitee; submission or account creation tied to token.
- **OIDC first-login (JIT):** First login via OIDC creates/links User and optionally Member from IdP data.
- Both must be design-ready so they can attach to the same approval or creation pipeline later.
- **Invite link (tokenized):** unique link per invitee; submission or account creation tied to the token.
- **OIDC first-login (JIT):** first OIDC login creates/links a User and optionally a Member from IdP data.
- Both must be design-ready so they can attach to the same approval/creation pipeline later.
---
## 5. Evaluation of the Proposed Concept Draft
## 5. Concept Evaluation — adopted decisions
**Adopted and reflected above:**
- **Naming:** resource **JoinRequest** (one resource, status + audit timestamps).
- **No User/Member from `/join`:** only a JoinRequest, created on submit (`pending_confirmation`), updated to `submitted` on confirmation. Member/User domain unchanged.
- **Public actions:** `submit` (create with `pending_confirmation` + send email) and `confirm` (update to `submitted`).
- **Public paths:** `/join` explicitly added to the plug's public path list; `/confirm_join/:token` covered by the existing `/confirm*` rule.
- **Minimal data:** email technically required; other fields from the admin-configured set, with optional "required" per field.
- **Security:** honeypot + rate limiting in MVP; email confirmation before "submitted"; token stored as hash; 24h retention + hard-delete for expired pending.
- **Naming:** Resource name **JoinRequest** (one resource, status + audit timestamps).
- **No User/Member from `/join`:** Only a JoinRequest; record is **created on form submit** (status `pending_confirmation`) and **updated to** `submitted` on confirmation. Abuse surface and policy complexity stay low.
- **Dedicated resource and actions:** New resource `JoinRequest` with public actions: **submit** (create with `pending_confirmation` + send email) and **confirm** (update to `submitted`). Member/User domain unchanged.
- **Public paths:** `/join` is **explicitly** added to the page-permission plugs public path list; confirmation route `/confirm_join/:token` is covered by existing `/confirm*` rule.
- **Minimal data:** Email is technically required; other fields from admin-configured join-form field set, with optional "required" per field.
- **Security:** Honeypot + rate limiting in MVP; email confirmation before treating request as submitted; token stored as hash; 24h retention and hard-delete for expired pending.
- **Tests:** Unauthenticated GET `/join` → 200; submit creates one JoinRequest (`pending_confirmation`); confirm updates it to `submitted`; idempotent confirm; honeypot and rate limiting covered; public-path tests updated.
**Refinements in this document:**
- Approval as Step 2; User creation after approval left open for later.
- Admin configurability: join form settings as own section; detailed UX in a subtask.
- Three entry paths (public, invite, OIDC) and their place in the roadmap made explicit.
- Pre-confirmation store: DB only, one resource, 24h retention, hard-delete.
- Payload: typed email (required), first_name, last_name; rest in jsonb with schema_version; rationale and what it depends on documented.
Refinements layered in this document: approval as Step 2 (User creation after approval left open); join-form settings as their own section (detailed UX in a subtask); three entry paths placed in the roadmap; pre-confirmation store DB-only with 24h hard-delete; payload split typed (email/first_name/last_name) + jsonb with schema_version.
---
@ -191,90 +156,60 @@ Implementation spec for Subtask 5.
**Decided:**
- **Email confirmation (double opt-in):** JoinRequest is **created on form submit** with status `pending_confirmation` and **updated to** `submitted` when the user clicks the confirmation link. Double opt-in is preserved (we only treat as "submitted" after the link is clicked). Existing confirmation pattern (AshAuthentication) is reused for token + email sender + route.
- **Email confirmation (double opt-in):** JoinRequest created on submit (`pending_confirmation`), updated to `submitted` on link click; treated as submitted only after the click. Reuses the existing AshAuthentication pattern (token + email sender + route).
- **Naming:** **JoinRequest**.
- **Pre-confirmation store:** **DB only.** Same JoinRequest resource; no ETS, no stateless token. Confirmation token stored as **hash** in DB; raw token only in email link. **24h** retention for `pending_confirmation`; **hard-delete** of expired records via scheduled job (e.g. Oban cron).
- **Confirmation route:** **`/confirm_join/:token`** so existing `starts_with?(path, "/confirm")` covers it.
- **Public path for `/join`:** **Add `/join` explicitly** to the page-permission plugs `public_path?/1` (e.g. in `CheckPagePermission`) so unauthenticated users can reach the join page.
- **JoinRequest schema:** Status `pending_confirmation` | `submitted` | `approved` | `rejected`. Typed: **email** (required), **first_name**, **last_name** (optional). **form_data** (jsonb) + **schema_version** for remaining form fields. **confirmation_token_hash**, **confirmation_token_expires_at**; **submitted_at**, **approved_at**, **rejected_at**, **reviewed_by_user_id**, **reviewed_by_display** (denormalized reviewer email for "Geprüft von" without loading User) for audit. Idempotent confirm (unique constraint on token hash or update only when status is `pending_confirmation`).
- **Approval outcome:** Admin-configurable. Default: approval creates Member only (no User). Optional "create User on approval" is **left for later**.
- **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.
- **Pre-confirmation store:** DB only, same resource; no ETS, no stateless token. Token stored as **hash**; raw token only in the email link. **24h** retention for `pending_confirmation`; **hard-delete** of expired records via scheduled job (Oban cron) — see `lib/mix/tasks/join_requests.cleanup_expired.ex`.
- **Confirmation route:** **`/confirm_join/:token`** so `starts_with?(path, "/confirm")` covers it.
- **Public path for `/join`:** explicitly add `/join` to the plug's `public_path?/1` (e.g. in `CheckPagePermission`).
- **JoinRequest schema:** status `pending_confirmation` | `submitted` | `approved` | `rejected`. Typed: **email** (required), **first_name**, **last_name** (optional). **form_data** (jsonb) + **schema_version** for the rest. **confirmation_token_hash**, **confirmation_token_expires_at**; **submitted_at**, **approved_at**, **rejected_at**, **reviewed_by_user_id**, **reviewed_by_display** (denormalized reviewer email for "Geprüft von" without loading User). Idempotent confirm (unique constraint on token hash, or update only when status is `pending_confirmation`).
- **Approval outcome:** admin-configurable; default Member only (no User); optional "create User on approval" left for later.
- **Rate limiting:** honeypot + rate limiting from the start (e.g. Hammer.Plug).
- **Settings:** own section "Onboarding / Join"; `join_form_enabled` + field selection; display as list/badges; detailed UX in a separate subtask.
- **Approval permission:** normal_user; JoinRequest read/update and the approval page added to normal_user; no new permission set.
- **Approval route:** `/join_requests` (list), `/join_requests/:id` (detail).
- **Resend confirmation:** if not in Prio 1, create a separate ticket immediately.
**Open for later:**
- Abuse metadata (IP hash etc.): classification and whether to store in Prio 1.
- "Create User on approval" option: to be specified when implemented.
- Invite link and OIDC JIT entry paths.
**Open for later:** abuse metadata (IP hash etc.) classification and whether to store in Prio 1; "create User on approval" option (specify when implemented); invite link and OIDC JIT entry paths.
---
## 7. Definition of Done (Prio 1)
- Public `/join` page and confirmation route reachable without login; **`/join` explicitly** in public paths (plug and tests).
- Flow: form submit → **JoinRequest created** in status `pending_confirmation` → confirmation email sent → user clicks link → **JoinRequest updated** to status `submitted`; no User or Member created by this flow.
- Public `/join` page and confirmation route reachable without login; `/join` explicitly in public paths (plug + tests).
- Flow: submit → JoinRequest `pending_confirmation` → email sent → click link → JoinRequest `submitted`; no User/Member created.
- Anti-abuse: honeypot and rate limiting implemented and tested.
- Cleanup: scheduled job hard-deletes JoinRequests in `pending_confirmation` older than 24h (or configured retention).
- Page-permission and routing tests updated (including public-path coverage for `/join` and `/confirm_join/:token`).
- Concept and decisions (§6) documented for use in implementation specs.
- Cleanup: scheduled job hard-deletes `pending_confirmation` JoinRequests older than 24h.
- Page-permission and routing tests updated (public-path coverage for `/join` and `/confirm_join/:token`).
- Concept and decisions (§6) documented for implementation specs.
---
## 8. Implementation Plan (Subtasks)
**Resend confirmation** remains a separate ticket (see §2.5, §6).
Resend confirmation remains a separate ticket (§2.5, §6).
### Prio 1 Public Join (4 subtasks)
**Prio 1 Public Join (4 subtasks, all shipped):**
#### 1. JoinRequest resource and public policies **(done)**
1. **JoinRequest resource and public policies** *(shipped)* — Ash resource per §2.3.2 (status, email required, first_name/last_name, form_data jsonb, schema_version, confirmation_token_hash + expiry, audit timestamps, source); migration; unique_index on confirmation_token_hash for idempotency. Public actions `submit` (create) and `confirm` (update) allowed with `actor: nil`; no system-actor fallback, no undocumented `authorize?: false`.
2. **Submit and confirm flow** *(shipped)* — submit creates JoinRequest + sends confirmation email (reuse AshAuthentication sender); `/confirm_join/:token` verifies token (hash + lookup), updates to `submitted`, sets submitted_at, invalidates token (idempotent if already submitted); Oban hard-delete job for expired `pending_confirmation`.
3. **Admin: Join form settings** *(shipped)* — "Onboarding / Join" settings section (§2.6): `join_form_enabled`, field selection (member_fields + custom fields), "required" per field; persisted; **server-side allowlist** available to subtask 4.
4. **Public join page and anti-abuse** *(shipped)* — public `/join` route added to the plug's public path list; LiveView with fields from the allowlist; copy per §2.5; honeypot + rate limiting (Hammer.Plug); after-submit and expired-link copy; public-path tests updated to include `/join`.
- **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).
**Order and dependencies:** 1 → 2 (flow uses the resource); 3 before/parallel with 4 (form reads the allowlist from settings; MVP subtask 4 can use a default allowlist with 3 following shortly). Recommended: 1 → 2 → 3 → 4.
#### 2. Submit and confirm flow **(done)**
**Step 2 Approval (1 subtask, shipped):**
- **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 **(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 **(done)**
- **Scope:** Route **`/join`** (public). **Add `/join` to the page-permission plugs public path list** so unauthenticated access is allowed. LiveView (or controller + form). Form fields from allowlist (subtask 3); copy per §2.5. **Honeypot** and **rate limiting** (e.g. Hammer.Plug) on join/submit. After submit: show "We have saved your details … click the link …". Expired-link page: clear message + "submit form again". Public-path tests updated to include `/join`.
- **Boundary:** No approval UI, no User/Member creation only public page, form, anti-abuse, and wiring to submit/confirm flow (subtask 2).
- **Done:** Unauthenticated GET `/join` → 200; submit creates JoinRequest (pending_confirmation) and sends email; confirm updates to submitted; honeypot and rate limit tested; public-path tests updated.
### Order and dependencies
- **1 → 2:** Submit/confirm flow uses JoinRequest resource.
- **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)
- **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.
5. **Approval UI (Vorstand)** *(shipped)* — routes `/join_requests` (list) → `JoinRequestLive.Index`, `/join_requests/:id` (detail) → `JoinRequestLive.Show`; full spec in §3.1. Lists submitted JoinRequests, approve/reject; on approve creates a Member (no User in MVP). Permission: normal_user has JoinRequest read/update and the two pages in PermissionSets; audit fields populated; promotion JoinRequest → Member via `Mv.Membership.approve_join_request/2` per §3.1.
---
## 9. References
- `docs/roles-and-permissions-architecture.md` Permission sets, roles, page permissions.
- `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.
- Issue #308 Original feature/planning context.
- `docs/roles-and-permissions-architecture.md` — permission sets, roles, page permissions.
- `docs/page-permission-route-coverage.md` — public paths, plug behaviour, tests; covers `/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/authorization/checks/actor_is_nil.ex` — the actor:nil public-action check.
- `lib/mix/tasks/join_requests.cleanup_expired.ex` — hard-delete of expired `pending_confirmation` JoinRequests (24h retention).
- `lib/mv/accounts/user/senders/send_new_user_confirmation_email.ex` — existing confirmation-email pattern (token, link, Mailer).
- Hammer / Hammer.Plug (hexdocs.pm/hammer) — rate limiting for Phoenix/Plug.
- Issue #308 — original feature/planning context.