27 KiB
Onboarding & Join – High-Level Concept
Status: Draft for design decisions and implementation specs. Prio 1 (Subtasks 1–4) implemented.
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: the JoinRequest record is created in the database 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 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.
- Elixir/Phoenix/Ash standard: Data is persisted in the database from the start (one Ash resource, status-driven flow). No ETS or stateless token for pre-confirmation storage; confirm flow only updates the existing record.
2.2 User Flow (Prio 1)
- Unauthenticated user opens
/join. - Short explanation + form (what happens next: "We will review … you will hear from us").
- Submit → A JoinRequest is created in the database with status
pending_confirmation; confirmation email is 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 → The existing JoinRequest is updated to status
submitted(submitted_atset, confirmation token invalidated); user sees: "Thank you, we have received your request."
Rationale (double opt-in with DB-first): Email confirmation remains best practice (we only treat the request as "submitted" after the link is clicked). The record exists in the DB from submit time so we use standard Phoenix/Ash persistence, multi-node safety, and a simple status transition (pending_confirmation → submitted) on confirm. This aligns with patterns like AshAuthentication (resource exists before confirm; confirm updates state).
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 is enforced in the LiveView (
build_submit_attrs) and in the resource viaJoinRequest.Changes.FilterFormDataByAllowlistso that even direct API/submit_join_request calls only persist allowlisted form_data keys. - On form submit: Create a JoinRequest with 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 (see §2.3.2), then send confirmation email. - On confirmation link click: Update the JoinRequest (find by token hash): set status to
submitted, setsubmitted_at, clear/invalidate token fields. If the record is alreadysubmitted, return success without changing it (idempotent). - 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 (Decided)
Decision: Store in the database only. Use the same JoinRequest resource and table from the start.
- On submit: create one JoinRequest row with status
pending_confirmation, confirmation token hash, and expiry. - On confirm: update that row to status
submitted(no second table, no ETS, no stateless token). - Retention and cleanup: JoinRequests that remain in
pending_confirmationpast the token expiry (e.g. 24 hours) are hard-deleted by a scheduled job (e.g. Oban cron). Retention period: 24 hours; document in DSGVO/retention as needed. - Rationale: Elixir/Phoenix/Ash standard is persistence in DB, one resource, status machine. Multi-node safe, restart safe, and cleanup is a standard cron task.
2.3.2 JoinRequest: Data Model and Schema
- Status:
pending_confirmation(initial, after form submit) →submitted(after link click) → laterapproved/rejected. Include approved_at, rejected_at, reviewed_by_user_id for audit. - Confirmation: Store confirmation_token_hash (not the raw token); confirmation_token_expires_at; optional confirmation_sent_at. Raw token appears only in the email link; on confirm, hash the incoming token and find the record by hash.
- Payload vs typed columns (recommendation):
- Typed columns for email (required, dedicated field for index, search, dedup, audit) and for first_name and last_name (optional). These align with
Mv.Constants.member_fields()and with the existing Member resource; they support approval-list display and straightforward promotion to Member without parsing JSON. - Remaining form data (other member fields + custom field values) in a jsonb attribute (e.g.
form_data) plus a schema_version (e.g. tied to join-form or member_fields evolution) so future changes do not break existing records. - What it depends on: (1) Whether the join form field set is fixed or often extended – if fixed, more typed columns are feasible; if very dynamic, keeping the rest in jsonb avoids migrations. (2) Whether the approval UI or reporting needs to filter/sort by other fields (e.g. city) – if yes, consider adding those as typed columns later. For MVP, email + first_name + last_name typed and rest in jsonb is a good balance with the current codebase (Member has typed attributes; export/import use allowlists of field names).
- Typed columns for email (required, dedicated field for index, search, dedup, audit) and for first_name and last_name (optional). These align with
- Logger hygiene: Do not log the full payload/form_data; follow CODE_GUIDELINES on log sanitization.
- Idempotency: Confirm action finds the JoinRequest by token hash; if status is already
submitted, return success without updating. Optionally enforce unique_index on confirmation_token_hash so the same token cannot apply to more than one record. - Abuse metadata: If stored (e.g. IP hash), classify as security telemetry or personally identifiable (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?/1so that the join page is reachable without login. Do not rely on the confirm path alone. - Confirmation route: Use
/confirm_join/:tokenso that 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 (MVP) plus rate limiting (MVP). Use Phoenix/Elixir standard options (e.g. Hammer with Hammer.Plug, ETS backend), scoped to the join flow (e.g. by IP). Client IP for rate limiting: prefer X-Forwarded-For / X-Real-IP when behind a reverse proxy (see Endpoint
connect_info: [:x_headers]andJoinLive.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 before or during implementation. - Data: Minimal PII; no sensitive data on the public form; consider DSGVO when extending. If abuse signals are stored: only hashed or aggregated values; document classification and retention.
- 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 has explicit public actions allowed with
actor: nil(e.g.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 "missing actor". Use explicit unauthenticated context; see CODE_GUIDELINES §5.0.
2.5 Usability and UX
- After submit: Communicate clearly: e.g. "We have saved your details. To complete your request, please click the link we sent to your email." (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 after confirm: 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. If not implemented in Prio 1, create a separate ticket immediately. 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 (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/joinpage is active and the following config applies. - Copyable join link: When the join form is enabled, a copyable full URL to the
/joinpage is shown below the checkbox (above the field list), with a short hint so admins can share it with applicants. - 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). Adding fields: e.g. dropdown or modal to pick from remaining fields. Detailed UX for this subsection is to be specified in a separate subtask. - Technically required fields: The only field that must always be required for the join flow is email. 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 status "submitted") and approve or reject.
- Route:
/join_requestsfor the approval UI (list and detail). See §3.1 for full specification. - 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. invite-to-set-password). This is open for later; implementation concepts will be detailed when that option is implemented.
- Permissions: Approval uses the existing permission set normal_user (e.g. role "Kassenwart"). JoinRequest gets read and update (or dedicated approve/reject actions) for scope :all in normal_user, and
/join_requests(and/join_requests/:idfor detail) are added to normal_user’s allowed pages.
3.1 Step 2 – Approval (detail)
Implementation spec for Subtask 5.
Route and pages
- List:
/join_requests– list of join requests. Filter by status (default or primary view: statussubmitted); optional view for "all" or "approved/rejected" for audit. - Detail:
/join_requests/:id– single join request. 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. Actions Approve / Reject when status issubmitted.
Backend (JoinRequest)
- New actions (authenticated only):
approve(update): allowed only when status issubmitted. Sets statusapproved,approved_at,reviewed_by_user_id(actor). Triggers promotion to Member (see Promotion below).reject(update): allowed only when status issubmitted. Sets statusrejected,rejected_at,reviewed_by_user_id. No reason field in MVP.
- Policies:
approveandrejectpermitted via HasPermission for permission set normal_user (read/update or explicit approve/reject on JoinRequest, scope :all). Not allowed foractor: nil. - Domain: Expose
list_join_requests/1(e.g. filter by status, with actor),approve_join_request/2(id, actor),reject_join_request/2(id, actor). Read action for JoinRequest for normal_user scope :all so list/detail can load data.
Promotion: JoinRequest → Member
- When: On successful
approveonly (status wassubmitted). - Mapping:
- JoinRequest typed fields → Member: email, first_name, last_name copied to Member attributes.
- form_data (jsonb): keys that match
Mv.Constants.member_fields()(atom names or string keys) → corresponding Member attributes. Keys that are custom field IDs (UUID format) → create CustomFieldValue records linked to the new Member. - Defaults: e.g.
join_date= today if not in form_data;membership_fee_type_id= default from settings (or first available) if not provided. Handle required Member validations (e.g. email already present from JoinRequest).
- Implementation: Prefer a single Ash change (e.g.
JoinRequest.Changes.PromoteToMember) or a domain function that builds member attributes + custom_field_values from the approved JoinRequest and calls Membercreate_member(actor: reviewer or system actor as per CODE_GUIDELINES; document choice). No User created in MVP. - Atomicity: The approve flow (get JoinRequest → update to approved → promote to Member) runs inside
Ash.transact(JoinRequest, fn -> ... end)so that if Member creation fails (e.g. validation, unique constraint), the JoinRequest status is rolled back and remains consistent. - Idempotency: If approve is called again by mistake (e.g. race), either reject transition when status is already
approvedor ensure Member creation is idempotent (e.g. do not create duplicate Member for same JoinRequest).
Permission sets and routing
- PermissionSets (normal_user): Add JoinRequest read :all and update :all (or approve / reject if using dedicated actions). Add pages
/join_requestsand/join_requests/:idto the normal_user pages list. - Router: Register live routes for list and detail; add entries to page-permission-route-coverage.md and extend plug tests so normal_user is allowed, read_only/own_data denied.
UI/UX (approval)
- List: Table or card list with columns e.g. submitted_at, first_name, last_name, email, status. Primary filter or default filter: status =
submitted. Buttons or links to open detail. Follow existing list patterns (e.g. Members/Groups): header, back link, CoreComponents table. - Detail: Show all request data (typed + form_data rendered by field). Buttons: Approve (primary), Reject (secondary). Reject in MVP: no reason field; just set status to rejected and audit fields.
- Accessibility and i18n: Same standards as rest of app (labels, Gettext, keyboard, ARIA where needed).
Tests
- JoinRequest: 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 and audit fields; approve when already approved is no-op or error.
- Page permission: normal_user can GET
/join_requestsand/join_requests/:id; read_only/own_data cannot. - Optional: LiveView smoke test – list loads, approve/reject from detail updates state.
4. Future Entry Paths (Out of Scope Here)
- Invite link (tokenized): Unique link per invitee; submission or account creation tied to token.
- OIDC first-login (JIT): First login via OIDC creates/links User and optionally Member from IdP data.
- Both must be design-ready 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 + audit timestamps).
- No User/Member from
/join: Only a JoinRequest; record is created on form submit (statuspending_confirmation) and updated tosubmittedon confirmation. Abuse surface and policy complexity stay low. - Dedicated resource and actions: New resource
JoinRequestwith public actions: submit (create withpending_confirmation+ send email) and confirm (update tosubmitted). Member/User domain unchanged. - Public paths:
/joinis explicitly added to the page-permission plug’s public path list; confirmation route/confirm_join/:tokenis covered by existing/confirm*rule. - Minimal data: Email is technically required; other fields from admin-configured join-form field set, with optional "required" per field.
- Security: Honeypot + rate limiting in MVP; email confirmation before treating request as submitted; token stored as hash; 24h retention and hard-delete for expired pending.
- Tests: Unauthenticated GET
/join→ 200; submit creates one JoinRequest (pending_confirmation); confirm updates it tosubmitted; idempotent confirm; honeypot and rate limiting covered; public-path tests updated.
Refinements in this document:
- Approval as Step 2; User creation after approval left open for later.
- Admin configurability: join form settings as own section; detailed UX in a subtask.
- Three entry paths (public, invite, OIDC) and their place in the roadmap made explicit.
- Pre-confirmation store: DB only, one resource, 24h retention, hard-delete.
- Payload: typed email (required), first_name, last_name; rest in jsonb with schema_version; rationale and what it depends on documented.
6. Decisions and Open Points
Decided:
- Email confirmation (double opt-in): JoinRequest is created on form submit with status
pending_confirmationand updated tosubmittedwhen the user clicks the confirmation link. Double opt-in is preserved (we only treat as "submitted" after the link is clicked). Existing confirmation pattern (AshAuthentication) is reused for token + email sender + route. - Naming: JoinRequest.
- Pre-confirmation store: DB only. Same JoinRequest resource; no ETS, no stateless token. Confirmation token stored as hash in DB; raw token only in email link. 24h retention for
pending_confirmation; hard-delete of expired records via scheduled job (e.g. Oban cron). - Confirmation route:
/confirm_join/:tokenso existingstarts_with?(path, "/confirm")covers it. - Public path for
/join: Add/joinexplicitly to the page-permission plug’spublic_path?/1(e.g. inCheckPagePermission) so unauthenticated users can reach the join page. - JoinRequest schema: Status
pending_confirmation|submitted|approved|rejected. Typed: email (required), first_name, last_name (optional). form_data (jsonb) + schema_version for remaining form fields. confirmation_token_hash, confirmation_token_expires_at; submitted_at, approved_at, rejected_at, reviewed_by_user_id for audit. Idempotent confirm (unique constraint on token hash or update only when status ispending_confirmation). - Approval outcome: Admin-configurable. Default: approval creates Member only (no User). Optional "create User on approval" is left for later.
- Rate limiting: Honeypot + rate limiting from the start (e.g. Hammer.Plug).
- Settings: Own section "Onboarding / Join" in global settings;
join_form_enabledplus field selection; display as list/badges; detailed UX in a separate subtask. - Approval permission: normal_user; JoinRequest read/update and approval page added to normal_user; no new permission set.
- Approval route:
/join_requests(list) and/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: to be specified 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 and tests). - Flow: form submit → JoinRequest created in status
pending_confirmation→ confirmation email sent → user clicks link → JoinRequest updated to statussubmitted; no User or Member created by this flow. - Anti-abuse: honeypot and rate limiting implemented and tested.
- Cleanup: scheduled job hard-deletes JoinRequests in
pending_confirmationolder than 24h (or configured retention). - Page-permission and routing tests updated (including public-path coverage for
/joinand/confirm_join/:token). - Concept and decisions (§6) documented for use in implementation specs.
8. Implementation Plan (Subtasks)
Resend confirmation remains a separate ticket (see §2.5, §6).
Prio 1 – Public Join (4 subtasks)
1. JoinRequest resource and public policies (done)
- Scope: Ash resource
JoinRequestper §2.3.2: status (pending_confirmation,submitted,approved,rejected), email (required), first_name, last_name (optional), form_data (jsonb), schema_version; confirmation_token_hash, confirmation_token_expires_at; submitted_at, approved_at, rejected_at, reviewed_by_user_id, source. Migration; unique_index on confirmation_token_hash (or equivalent for idempotency). - Policies: Public actions submit (create) and confirm (update) allowed with
actor: nil; no system-actor fallback, no undocumentedauthorize?: false. - Boundary: No UI, no emails – only resource, persistence, and actions callable with nil actor.
- Done: Resource and migration in place; tests for create/update with
actor: niland for idempotent confirm (same token twice → no second update).
2. Submit and confirm flow (done)
- Scope: Form submit → create JoinRequest (status
pending_confirmation, token hash + expiry, form data) → send confirmation email (reuse AshAuthentication sender pattern). Route/confirm_join/:token→ verify token (hash and lookup) → update JoinRequest to statussubmitted, set submitted_at, invalidate token (idempotent if already submitted). Optional: Oban (or similar) job to hard-delete JoinRequests inpending_confirmationwith confirmation_token_expires_at older than 24h. - Boundary: No join-form UI, no admin settings – only backend create/update and email/route.
- Done: Submit creates one JoinRequest; confirm updates it to submitted; double-click idempotent; expired token shows clear message; cleanup job implemented and documented. Tests for these cases.
3. Admin: Join form settings (done)
- 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 in sub-subtask if needed). - Boundary: No public form – 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 (done)
- Scope: Route
/join(public). Add/jointo the page-permission plug’s public path list so unauthenticated access is allowed. LiveView (or controller + form). Form fields from allowlist (subtask 3); copy per §2.5. Honeypot and rate limiting (e.g. Hammer.Plug) on join/submit. After submit: show "We have saved your details … click the link …". Expired-link page: clear message + "submit form again". Public-path tests updated to include/join. - Boundary: No approval UI, no User/Member creation – only public page, form, anti-abuse, and wiring to submit/confirm flow (subtask 2).
- Done: Unauthenticated GET
/join→ 200; submit creates JoinRequest (pending_confirmation) and sends email; confirm updates to submitted; honeypot and rate limit tested; public-path tests updated.
Order and dependencies
- 1 → 2: Submit/confirm flow uses JoinRequest resource.
- 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)
- Route:
/join_requests(list),/join_requests/:id(detail). Full specification: §3.1. - Scope: List JoinRequests (status "submitted"), approve/reject actions; on approve create Member (no User in MVP). Permission: normal_user; add JoinRequest read/update (or approve/reject) and pages
/join_requests,/join_requests/:idto PermissionSets. Populate audit fields (approved_at, rejected_at, reviewed_by_user_id). Promotion: JoinRequest → Member per §3.1 (mapping, defaults, idempotency). - 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; add/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/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.
- Issue #308 – Original feature/planning context.