mitgliederverwaltung/docs/onboarding-join-concept.md

22 KiB
Raw Blame History

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.RouterMvWeb.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_requestsJoinRequestLive.Index and /join_requests/:idJoinRequestLive.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):

  1. 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.