mitgliederverwaltung/docs/onboarding-join-concept.md
Simon 883e7a3e62
Some checks reported errors
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/production Build is failing
docs: concept self-sign-up
2026-02-20 14:53:59 +01:00

218 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:** 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 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.