mitgliederverwaltung/docs/onboarding-join-concept.md

215 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Onboarding & Join High-Level Concept
**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 (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.
---
## 2. Prio 1: Public Join Page
### 2.1 Intent
- **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
1. Unauthenticated user opens `/join`.
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, 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 (§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, 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.
**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) → `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 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:** "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).
- 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".
- **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 (implemented)
- **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) — implemented in Subtask 5
**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`.
**Backend (`Mv.Membership.JoinRequest`) — actions (authenticated only):**
- **`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).
**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.
**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.
**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.
**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, not yet implemented)
- **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. Concept Evaluation — adopted decisions
- **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.
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.
---
## 6. Decisions and Open Points
**Decided:**
- **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 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 (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 + 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 `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 (§2.5, §6).
**Prio 1 Public Join (4 subtasks, all shipped):**
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`.
**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.
**Step 2 Approval (1 subtask, shipped):**
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; 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.