22 KiB
Onboarding & Join – High-Level Concept
Status: Prio 1 (Subtasks 1–4) 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 tosubmittedafter the user clicks the confirmation link. - This keeps public intake (abuse-prone) separate from identity/account creation, and leaves existing policies (User–Member 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
- Unauthenticated user opens
/join. - Short explanation + form ("We will review … you will hear from us").
- 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." - User clicks confirmation link → existing JoinRequest updated to
submitted(submitted_atset, 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 viaJoinRequest.Changes.FilterFormDataByAllowlist, so even direct API /submit_join_requestcalls persist only allowlistedform_datakeys. - On submit: create a JoinRequest (status
pending_confirmation), store confirmation token hash in the DB (raw token only in the email link), setconfirmation_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, setsubmitted_at, clear/invalidate token fields. If alreadysubmitted, 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) → laterapproved/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:
/joinand 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'spublic_path?/1; do not rely on the confirm path alone. - Confirmation route: use
/confirm_join/:tokenso the existing whitelist (e.g.String.starts_with?(path, "/confirm")) already covers it — no extra plug change for confirm.
- Explicit public path for
- 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(submitfor create,confirmfor update). Model via policies that permit these actions when actor is nil; do not useauthorize?: falseunless 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/joinpage is active and the config below applies. - Copyable join link: when enabled, a copyable full URL to
/joinis 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 inMvWeb.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_requestsand/join_requests/:idare 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 issubmitted.
Backend (Mv.Membership.JoinRequest) — actions (authenticated only):
approve(update, changeJoinRequest.Changes.ApproveRequest): allowed only when status issubmitted. Setsapproved,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, changeJoinRequest.Changes.RejectRequest): allowed only when status issubmitted. Setsrejected,rejected_at,reviewed_by_user_id. No reason field in MVP.- Policies:
approveandrejectare each permitted viaHasPermission; the read policy usesHasJoinRequestAccess(a SimpleCheck) so list/detail can load data. Not allowed foractor: 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
approveonly (status wassubmitted). - 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_idis not set here; the Membercreate_memberaction applies the default fee type from settings (seeMv.Membership.Member.Changes.SetDefaultMembershipFeeType). - Implementation: the domain function
Mv.Membership.approve_join_request/2calls the privatepromote_to_member/2, which builds member attributes + custom_field_values and calls Membercreate_memberwith 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:
ApproveRequestonly transitions fromsubmitted; a repeated approve on an already-approvedrequest 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_requestsand/join_requests/:id. - Router (
MvWeb.Router): live routes/join_requests→JoinRequestLive.Indexand/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 tosubmittedon confirmation. Member/User domain unchanged. - Public actions:
submit(create withpending_confirmation+ send email) andconfirm(update tosubmitted). - Public paths:
/joinexplicitly added to the plug's public path list;/confirm_join/:tokencovered 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 tosubmittedon 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) — seelib/mix/tasks/join_requests.cleanup_expired.ex. - Confirmation route:
/confirm_join/:tokensostarts_with?(path, "/confirm")covers it. - Public path for
/join: explicitly add/jointo the plug'spublic_path?/1(e.g. inCheckPagePermission). - 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 ispending_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
/joinpage and confirmation route reachable without login;/joinexplicitly in public paths (plug + tests). - Flow: submit → JoinRequest
pending_confirmation→ email sent → click link → JoinRequestsubmitted; no User/Member created. - Anti-abuse: honeypot and rate limiting implemented and tested.
- Cleanup: scheduled job hard-deletes
pending_confirmationJoinRequests older than 24h. - Page-permission and routing tests updated (public-path coverage for
/joinand/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):
- 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) andconfirm(update) allowed withactor: nil; no system-actor fallback, no undocumentedauthorize?: false. - Submit and confirm flow (shipped) — submit creates JoinRequest + sends confirmation email (reuse AshAuthentication sender);
/confirm_join/:tokenverifies token (hash + lookup), updates tosubmitted, sets submitted_at, invalidates token (idempotent if already submitted); Oban hard-delete job for expiredpending_confirmation. - 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. - Public join page and anti-abuse (shipped) — public
/joinroute 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):
- 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 viaMv.Membership.approve_join_request/2per §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_requestsand/join_requests/:idfor Step 2 (normal_user).lib/mv_web/plugs/check_page_permission.ex— public path list; add/joininpublic_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 expiredpending_confirmationJoinRequests (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.