From 4432e2770b8492836e87a76b6e758487aa3891af Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 20 Feb 2026 13:40:31 +0100 Subject: [PATCH] concept web form #308 --- docs/onboarding-join-concept.md | 147 ++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 docs/onboarding-join-concept.md diff --git a/docs/onboarding-join-concept.md b/docs/onboarding-join-concept.md new file mode 100644 index 0000000..96445c5 --- /dev/null +++ b/docs/onboarding-join-concept.md @@ -0,0 +1,147 @@ +# 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. User–Member 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. **Implementation decision required:** The choice between a transient store (e.g. ETS with TTL) and a dedicated DB table (e.g. “pending join” records) must be made in the implementation spec. Either way: define TTL/expiry, cleanup of unconfirmed data, and minimal PII retention (DSGVO). Document the decision and retention rules. +- **Post-confirmation:** When the user clicks the confirmation link, create the **JoinRequest** resource with: + - Submitted payload (minimal, well-defined schema; only whitelisted fields). + - Status: `submitted` (later: `approved`, `rejected`). + - Server-set metadata: `submitted_at`, locale, source (e.g. `public_join`), optional abuse signals (e.g. IP hash, spam score). +- **No creation** of Member or User in Prio 1; promotion to Member/User happens in a later step (e.g. after approval). + +### 2.4 Security + +- **Public path:** `/join` (and the confirmation route, e.g. `/join/confirm/:token`) must be treated as public. Add them to the public-path list used by the page-permission plug so unauthenticated access returns 200, not redirect to sign-in. +- **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. +- **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 + +- 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; document as a possible future improvement (e.g. “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_user’s 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 the confirmation route (e.g. `/join/confirm/:token`) in the public-path whitelist (page-permission plug). +- **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). + +--- + +## 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. 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.