From 4432e2770b8492836e87a76b6e758487aa3891af Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 20 Feb 2026 13:40:31 +0100 Subject: [PATCH 1/4] 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. -- 2.47.2 From 883e7a3e628887420162cecaaebc8317470caa63 Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 20 Feb 2026 14:53:59 +0100 Subject: [PATCH 2/4] docs: concept self-sign-up --- docs/onboarding-join-concept.md | 87 ++++++++++++++++++++++++++++++--- 1 file changed, 79 insertions(+), 8 deletions(-) diff --git a/docs/onboarding-join-concept.md b/docs/onboarding-join-concept.md index 96445c5..9c09da7 100644 --- a/docs/onboarding-join-concept.md +++ b/docs/onboarding-join-concept.md @@ -42,28 +42,49 @@ ### 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. +- **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). - - 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). + - 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, 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. +- **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 plug’s `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; document as a possible future improvement (e.g. “Request new confirmation email” on the “Please confirm your email” or expired-link page). +- **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 @@ -101,7 +122,7 @@ - **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). +- **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. @@ -124,6 +145,10 @@ - **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. --- @@ -137,7 +162,53 @@ --- -## 8. References +## 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. -- 2.47.2 From e7393e32d81732a0473851518ec6ad4e83d49451 Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 20 Feb 2026 17:37:51 +0100 Subject: [PATCH 3/4] feat: join request backend --- docs/development-progress-log.md | 9 ++ docs/onboarding-join-concept.md | 4 +- lib/membership/join_request.ex | 141 ++++++++++++++++++ lib/membership/membership.ex | 46 ++++++ .../20260220120000_add_join_requests.exs | 42 ++++++ test/mv/membership/join_request_test.exs | 104 +++++++++++++ 6 files changed, 344 insertions(+), 2 deletions(-) create mode 100644 lib/membership/join_request.ex create mode 100644 priv/repo/migrations/20260220120000_add_join_requests.exs create mode 100644 test/mv/membership/join_request_test.exs diff --git a/docs/development-progress-log.md b/docs/development-progress-log.md index 1dcf994..2f96345 100644 --- a/docs/development-progress-log.md +++ b/docs/development-progress-log.md @@ -335,6 +335,15 @@ end - show custom fields in member overview per default - can be set to false in the settings for the specific custom field +--- + +**Onboarding / Public Join (Issue #308) – Subtask 1: JoinRequest resource and public policies** +- JoinRequest Ash resource (`lib/membership/join_request.ex`) per concept §2.3.2: email, confirmation_token_hash, status, submitted_at, source, schema_version, payload, approved_at, rejected_at, reviewed_by_user_id +- Migration `20260220120000_add_join_requests.exs` with unique index on `confirmation_token_hash` for idempotency +- Public policies: `:confirm` and `:read` allowed with `actor: nil`; generic `:create` requires HasPermission +- Domain interface: `confirm_join_request/2`, `list_join_requests/1`, `get_join_request/2`, `update_join_request/2`, `destroy_join_request/1` +- Tests: `test/mv/membership/join_request_test.exs` – public create/read with nil, idempotency, validations (no UI/email yet) + ## Implementation Decisions ### Architecture Patterns diff --git a/docs/onboarding-join-concept.md b/docs/onboarding-join-concept.md index 9c09da7..1959c1b 100644 --- a/docs/onboarding-join-concept.md +++ b/docs/onboarding-join-concept.md @@ -168,12 +168,12 @@ The feature is split into a small number of well-bounded subtasks. **Resend conf ### Prio 1 – Public Join (4 subtasks) -#### 1. JoinRequest resource and public policies +#### 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). +- **Done:** Resource and migration in place; tests in `test/mv/membership/join_request_test.exs` for create/read with `actor: nil` and for idempotency (same token twice → no second record). #### 2. Pre-confirmation store and confirm flow diff --git a/lib/membership/join_request.ex b/lib/membership/join_request.ex new file mode 100644 index 0000000..466840b --- /dev/null +++ b/lib/membership/join_request.ex @@ -0,0 +1,141 @@ +defmodule Mv.Membership.JoinRequest do + @moduledoc """ + Ash resource for public join requests (onboarding flow). + + Created only after email confirmation (double opt-in). Per concept §2.3.2: + - email (dedicated field), payload, schema_version, status, submitted_at, source + - approved_at, rejected_at, reviewed_by_user_id for audit (Step 2) + - confirmation_token_hash for idempotency (unique constraint) + """ + use Ash.Resource, + domain: Mv.Membership, + data_layer: AshPostgres.DataLayer, + authorizers: [Ash.Policy.Authorizer] + + postgres do + table "join_requests" + repo Mv.Repo + end + + actions do + defaults [:read, :destroy] + + create :create do + primary? true + accept [ + :email, + :confirmation_token_hash, + :status, + :submitted_at, + :source, + :schema_version, + :payload, + :approved_at, + :rejected_at, + :reviewed_by_user_id + ] + end + + create :confirm do + description "Public action: create JoinRequest after confirmation link click (actor: nil)" + accept [ + :email, + :confirmation_token_hash, + :status, + :submitted_at, + :source, + :schema_version, + :payload + ] + end + + update :update do + accept [:status, :approved_at, :rejected_at, :reviewed_by_user_id] + require_atomic? false + end + end + + policies do + policy action(:confirm) do + description "Allow public confirmation (actor nil) for join flow" + authorize_if Ash.Policy.Check.Builtins.actor_absent() + end + + policy action_type(:read) do + description "Allow read when actor nil (success page) or when user has permission" + authorize_if Ash.Policy.Check.Builtins.actor_absent() + authorize_if Mv.Authorization.Checks.HasPermission + end + + policy action(:create) do + description "Generic create only for authorized users; public uses :confirm" + authorize_if Mv.Authorization.Checks.HasPermission + end + + policy action_type([:update, :destroy]) do + authorize_if Mv.Authorization.Checks.HasPermission + end + end + + attributes do + uuid_v7_primary_key :id + + attribute :email, :string do + allow_nil? false + public? true + end + + attribute :confirmation_token_hash, :string do + allow_nil? false + public? true + end + + attribute :status, :string do + allow_nil? false + public? true + default "submitted" + end + + attribute :submitted_at, :utc_datetime_usec do + allow_nil? false + public? true + end + + attribute :source, :string do + allow_nil? false + public? true + end + + attribute :schema_version, :integer do + allow_nil? false + public? true + end + + attribute :payload, :map do + allow_nil? true + public? true + default %{} + end + + attribute :approved_at, :utc_datetime_usec do + allow_nil? true + public? true + end + + attribute :rejected_at, :utc_datetime_usec do + allow_nil? true + public? true + end + + attribute :reviewed_by_user_id, :uuid do + allow_nil? true + public? true + end + + timestamps() + end + + identities do + identity :unique_confirmation_token_hash, [:confirmation_token_hash] + end +end diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex index 74735e4..69a8110 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -78,6 +78,52 @@ defmodule Mv.Membership do define :list_member_groups, action: :read define :destroy_member_group, action: :destroy end + + resource Mv.Membership.JoinRequest do + define :list_join_requests, action: :read + define :get_join_request, action: :read, get_by: [:id] + define :update_join_request, action: :update + define :destroy_join_request, action: :destroy + end + end + + # Idempotent confirm: implemented in code so duplicate token returns {:ok, existing} (concept §2.3.2) + @doc """ + Creates a JoinRequest after confirmation link click (public action with actor: nil). + + Idempotent: if a JoinRequest with the same `confirmation_token_hash` already exists, + returns `{:ok, existing}` instead of creating a duplicate (per concept §2.3.2). + """ + def confirm_join_request(attrs, opts \\ []) do + hash = attrs[:confirmation_token_hash] || attrs["confirmation_token_hash"] + + if hash do + case get_join_request_by_confirmation_token_hash!(hash, opts) do + nil -> do_confirm_join_request(attrs, opts) + existing -> {:ok, existing} + end + else + do_confirm_join_request(attrs, opts) + end + end + + defp do_confirm_join_request(attrs, opts) do + Mv.Membership.JoinRequest + |> Ash.Changeset.for_create(:confirm, attrs) + |> Ash.create(Keyword.put(opts, :domain, __MODULE__)) + end + + defp get_join_request_by_confirmation_token_hash!(hash, opts) do + opts = Keyword.put(opts, :domain, __MODULE__) + + Mv.Membership.JoinRequest + |> Ash.Query.filter(confirmation_token_hash == ^hash) + |> Ash.read_one(opts) + |> case do + {:ok, %Mv.Membership.JoinRequest{} = existing} -> existing + {:ok, nil} -> nil + _ -> nil + end end # Singleton pattern: Get the single settings record diff --git a/priv/repo/migrations/20260220120000_add_join_requests.exs b/priv/repo/migrations/20260220120000_add_join_requests.exs new file mode 100644 index 0000000..00f764d --- /dev/null +++ b/priv/repo/migrations/20260220120000_add_join_requests.exs @@ -0,0 +1,42 @@ +defmodule Mv.Repo.Migrations.AddJoinRequests do + @moduledoc """ + Adds join_requests table for public join flow (onboarding concept §2.3.2). + """ + use Ecto.Migration + + def up do + create table(:join_requests, primary_key: false) do + add :id, :uuid, null: false, default: fragment("uuid_generate_v7()"), primary_key: true + add :email, :string, null: false + add :confirmation_token_hash, :string, null: false + add :status, :string, null: false + add :submitted_at, :utc_datetime_usec, null: false + add :source, :string, null: false + add :schema_version, :bigint, null: false + add :payload, :map, null: true + add :approved_at, :utc_datetime_usec, null: true + add :rejected_at, :utc_datetime_usec, null: true + add :reviewed_by_user_id, :uuid, null: true + + add :inserted_at, :utc_datetime_usec, + null: false, + default: fragment("(now() AT TIME ZONE 'utc')") + + add :updated_at, :utc_datetime_usec, + null: false, + default: fragment("(now() AT TIME ZONE 'utc')") + end + + create unique_index(:join_requests, [:confirmation_token_hash], + name: "join_requests_unique_confirmation_token_hash_index" + ) + end + + def down do + drop_if_exists unique_index(:join_requests, [:confirmation_token_hash], + name: "join_requests_unique_confirmation_token_hash_index" + ) + + drop table(:join_requests) + end +end diff --git a/test/mv/membership/join_request_test.exs b/test/mv/membership/join_request_test.exs new file mode 100644 index 0000000..fa81231 --- /dev/null +++ b/test/mv/membership/join_request_test.exs @@ -0,0 +1,104 @@ +defmodule Mv.Membership.JoinRequestTest do + @moduledoc """ + Tests for JoinRequest resource and public policies (Subtask 1: onboarding join concept). + + Covers: public create/read with actor nil, idempotency of confirm (confirmation_token_hash), + and minimal required attributes. No framework behaviour is tested; only our policies and constraints. + """ + use Mv.DataCase, async: false + + alias Mv.Membership + alias Mv.Membership.JoinRequest + + require Ash.Query + + # Minimal valid attributes for the public :confirm action (per concept §2.3.2) + defp valid_confirm_attrs(opts \\ []) do + token = Keyword.get(opts, :confirmation_token_hash, "hash_#{System.unique_integer([:positive])}") + [ + email: "join_#{System.unique_integer([:positive])}@example.com", + confirmation_token_hash: token, + status: "submitted", + submitted_at: DateTime.utc_now(), + source: "public_join", + schema_version: 1, + payload: %{} + ] + |> Enum.into(%{}) + end + + describe "Public policies (actor: nil)" do + test "confirm with actor nil succeeds" do + attrs = valid_confirm_attrs() + + assert {:ok, %JoinRequest{} = request} = + Membership.confirm_join_request(attrs, actor: nil) + + assert request.email == attrs.email + assert request.status == "submitted" + assert request.source == "public_join" + end + + test "read with actor nil succeeds for created join request" do + attrs = valid_confirm_attrs() + {:ok, created} = Membership.confirm_join_request(attrs, actor: nil) + + assert {:ok, %JoinRequest{} = read} = + Ash.get(JoinRequest, created.id, actor: nil, domain: Mv.Membership) + + assert read.id == created.id + assert read.email == created.email + end + + test "generic create with actor nil is forbidden" do + attrs = valid_confirm_attrs() + + assert {:error, %Ash.Error.Forbidden{errors: [%Ash.Error.Forbidden.Policy{}]}} = + JoinRequest + |> Ash.Changeset.for_create(:create, attrs) + |> Ash.create(actor: nil, domain: Mv.Membership) + end + end + + describe "Idempotency (confirmation_token_hash)" do + test "second create with same confirmation_token_hash does not create duplicate" do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + token = "idempotent_token_#{System.unique_integer([:positive])}" + attrs1 = valid_confirm_attrs(confirmation_token_hash: token) + attrs2 = valid_confirm_attrs(confirmation_token_hash: token) + attrs2 = %{attrs2 | email: "other_#{System.unique_integer([:positive])}@example.com"} + + assert {:ok, first} = Membership.confirm_join_request(attrs1, actor: nil) + + # Second call with same token: idempotent return {:ok, existing} (concept §2.3.2) + assert {:ok, second} = Membership.confirm_join_request(attrs2, actor: nil) + assert second.id == first.id, "idempotent confirm must return the existing record" + + count = + JoinRequest + |> Ash.Query.filter(confirmation_token_hash == ^token) + |> Ash.read!(actor: system_actor, domain: Mv.Membership, authorize?: false) + |> length() + + assert count == 1, "expected exactly one JoinRequest with this confirmation_token_hash, got #{count}" + end + end + + describe "Resource and validations" do + test "create with minimal required attributes succeeds" do + attrs = valid_confirm_attrs() + + assert {:ok, %JoinRequest{}} = Membership.confirm_join_request(attrs, actor: nil) + end + + test "email is required" do + attrs = valid_confirm_attrs() |> Map.delete(:email) + + assert {:error, %Ash.Error.Invalid{errors: errors}} = + Membership.confirm_join_request(attrs, actor: nil) + + assert Enum.any?(errors, fn e -> Map.get(e, :field) == :email end), + "expected an error for field :email, got: #{inspect(errors)}" + end + end +end -- 2.47.2 From b41f005d9ec00ff5c8544eb61438ef6de476b6b2 Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 20 Feb 2026 18:24:20 +0100 Subject: [PATCH 4/4] refactor: apply review notes --- lib/membership/join_request.ex | 30 +++++----- .../changes/set_confirm_server_metadata.ex | 18 ++++++ lib/membership/membership.ex | 41 ++++++------- lib/mv/authorization/permission_sets.ex | 1 + priv/gettext/de/LC_MESSAGES/default.po | 15 ----- priv/gettext/en/LC_MESSAGES/default.po | 15 ----- .../20260220120000_add_join_requests.exs | 4 +- ...oin_requests_schema_version_to_integer.exs | 18 ++++++ test/mv/membership/join_request_test.exs | 60 ++++++++++++------- 9 files changed, 110 insertions(+), 92 deletions(-) create mode 100644 lib/membership/join_request/changes/set_confirm_server_metadata.ex create mode 100644 priv/repo/migrations/20260220120001_alter_join_requests_schema_version_to_integer.exs diff --git a/lib/membership/join_request.ex b/lib/membership/join_request.ex index 466840b..ce67820 100644 --- a/lib/membership/join_request.ex +++ b/lib/membership/join_request.ex @@ -12,16 +12,25 @@ defmodule Mv.Membership.JoinRequest do data_layer: AshPostgres.DataLayer, authorizers: [Ash.Policy.Authorizer] + alias Ash.Policy.Check.Builtins, as: AshBuiltins + postgres do table "join_requests" repo Mv.Repo end actions do - defaults [:read, :destroy] + defaults [:destroy] + + # Admin: list and get by id (used with HasPermission) + read :admin_read do + description "List and get JoinRequests; requires permission (e.g. admin / normal_user)" + primary? true + end create :create do primary? true + accept [ :email, :confirmation_token_hash, @@ -38,15 +47,9 @@ defmodule Mv.Membership.JoinRequest do create :confirm do description "Public action: create JoinRequest after confirmation link click (actor: nil)" - accept [ - :email, - :confirmation_token_hash, - :status, - :submitted_at, - :source, - :schema_version, - :payload - ] + accept [:email, :confirmation_token_hash, :payload] + + change Mv.Membership.JoinRequest.Changes.SetConfirmServerMetadata end update :update do @@ -58,12 +61,11 @@ defmodule Mv.Membership.JoinRequest do policies do policy action(:confirm) do description "Allow public confirmation (actor nil) for join flow" - authorize_if Ash.Policy.Check.Builtins.actor_absent() + authorize_if AshBuiltins.actor_absent() end - policy action_type(:read) do - description "Allow read when actor nil (success page) or when user has permission" - authorize_if Ash.Policy.Check.Builtins.actor_absent() + policy action(:admin_read) do + description "List/get JoinRequests only with permission (admin, later normal_user)" authorize_if Mv.Authorization.Checks.HasPermission end diff --git a/lib/membership/join_request/changes/set_confirm_server_metadata.ex b/lib/membership/join_request/changes/set_confirm_server_metadata.ex new file mode 100644 index 0000000..23c9435 --- /dev/null +++ b/lib/membership/join_request/changes/set_confirm_server_metadata.ex @@ -0,0 +1,18 @@ +defmodule Mv.Membership.JoinRequest.Changes.SetConfirmServerMetadata do + @moduledoc """ + Ash Change that sets server-side metadata for the public :confirm action. + + Client may only send :email, :confirmation_token_hash, :payload (concept §2.3.2). + This change sets: status, submitted_at, source, schema_version so they cannot be forged. + """ + use Ash.Resource.Change + + @impl true + def change(changeset, _opts, _context) do + changeset + |> Ash.Changeset.force_change_attribute(:status, "submitted") + |> Ash.Changeset.force_change_attribute(:submitted_at, DateTime.utc_now()) + |> Ash.Changeset.force_change_attribute(:source, "public_join") + |> Ash.Changeset.force_change_attribute(:schema_version, 1) + end +end diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex index 69a8110..2e8d082 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -80,30 +80,30 @@ defmodule Mv.Membership do end resource Mv.Membership.JoinRequest do - define :list_join_requests, action: :read - define :get_join_request, action: :read, get_by: [:id] + define :list_join_requests, action: :admin_read + define :get_join_request, action: :admin_read, get_by: [:id] define :update_join_request, action: :update define :destroy_join_request, action: :destroy end end - # Idempotent confirm: implemented in code so duplicate token returns {:ok, existing} (concept §2.3.2) + # Idempotent confirm: duplicate token hits unique constraint -> return {:ok, nil} (no public read) @doc """ Creates a JoinRequest after confirmation link click (public action with actor: nil). Idempotent: if a JoinRequest with the same `confirmation_token_hash` already exists, - returns `{:ok, existing}` instead of creating a duplicate (per concept §2.3.2). + returns `{:ok, nil}` (no record returned; no public read for security). """ def confirm_join_request(attrs, opts \\ []) do - hash = attrs[:confirmation_token_hash] || attrs["confirmation_token_hash"] + case do_confirm_join_request(attrs, opts) do + {:ok, request} -> + {:ok, request} - if hash do - case get_join_request_by_confirmation_token_hash!(hash, opts) do - nil -> do_confirm_join_request(attrs, opts) - existing -> {:ok, existing} - end - else - do_confirm_join_request(attrs, opts) + {:error, %Ash.Error.Invalid{errors: errors}} = error -> + if unique_confirmation_token_violation?(errors), do: {:ok, nil}, else: error + + other -> + other end end @@ -113,17 +113,12 @@ defmodule Mv.Membership do |> Ash.create(Keyword.put(opts, :domain, __MODULE__)) end - defp get_join_request_by_confirmation_token_hash!(hash, opts) do - opts = Keyword.put(opts, :domain, __MODULE__) - - Mv.Membership.JoinRequest - |> Ash.Query.filter(confirmation_token_hash == ^hash) - |> Ash.read_one(opts) - |> case do - {:ok, %Mv.Membership.JoinRequest{} = existing} -> existing - {:ok, nil} -> nil - _ -> nil - end + defp unique_confirmation_token_violation?(errors) do + Enum.any?(errors, fn err -> + Map.get(err, :field) == :confirmation_token_hash or + ((pv = Map.get(err, :private_vars)) && + (is_list(pv) and Keyword.get(pv, :constraint_type) == :unique)) + end) end # Singleton pattern: Get the single settings record diff --git a/lib/mv/authorization/permission_sets.ex b/lib/mv/authorization/permission_sets.ex index fffc818..2686884 100644 --- a/lib/mv/authorization/permission_sets.ex +++ b/lib/mv/authorization/permission_sets.ex @@ -269,6 +269,7 @@ defmodule Mv.Authorization.PermissionSets do perm_all("Role") ++ perm_all("Group") ++ member_group_perms ++ + perm_all("JoinRequest") ++ perm_all("MembershipFeeType") ++ perm_all("MembershipFeeCycle"), pages: [ diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 0d661cf..c994491 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -2608,18 +2608,3 @@ msgstr "Import" #, elixir-autogen, elixir-format msgid "Value type cannot be changed after creation" msgstr "Der Wertetyp kann nach dem Erstellen nicht mehr geändert werden." - -#~ #: lib/mv_web/live/import_export_live.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "Export Members (CSV)" -#~ msgstr "Mitglieder exportieren (CSV)" - -#~ #: lib/mv_web/live/import_export_live.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Export functionality will be available in a future release." -#~ msgstr "Export-Funktionalität ist im nächsten release verfügbar." - -#~ #: lib/mv_web/live/import_export_live.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Import members from CSV files or export member data." -#~ msgstr "Importiere Mitglieder aus CSV-Dateien oder exportiere Mitgliederdaten." diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 371a028..1cef9be 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -2609,18 +2609,3 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Value type cannot be changed after creation" msgstr "" - -#~ #: lib/mv_web/live/import_export_live.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "Export Members (CSV)" -#~ msgstr "" - -#~ #: lib/mv_web/live/import_export_live.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Export functionality will be available in a future release." -#~ msgstr "" - -#~ #: lib/mv_web/live/import_export_live.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Import members from CSV files or export member data." -#~ msgstr "" diff --git a/priv/repo/migrations/20260220120000_add_join_requests.exs b/priv/repo/migrations/20260220120000_add_join_requests.exs index 00f764d..7894ad9 100644 --- a/priv/repo/migrations/20260220120000_add_join_requests.exs +++ b/priv/repo/migrations/20260220120000_add_join_requests.exs @@ -34,8 +34,8 @@ defmodule Mv.Repo.Migrations.AddJoinRequests do def down do drop_if_exists unique_index(:join_requests, [:confirmation_token_hash], - name: "join_requests_unique_confirmation_token_hash_index" - ) + name: "join_requests_unique_confirmation_token_hash_index" + ) drop table(:join_requests) end diff --git a/priv/repo/migrations/20260220120001_alter_join_requests_schema_version_to_integer.exs b/priv/repo/migrations/20260220120001_alter_join_requests_schema_version_to_integer.exs new file mode 100644 index 0000000..9e03173 --- /dev/null +++ b/priv/repo/migrations/20260220120001_alter_join_requests_schema_version_to_integer.exs @@ -0,0 +1,18 @@ +defmodule Mv.Repo.Migrations.AlterJoinRequestsSchemaVersionToInteger do + @moduledoc """ + Aligns schema_version with Ash attribute type :integer (concept review). + """ + use Ecto.Migration + + def up do + alter table(:join_requests) do + modify :schema_version, :integer, null: false + end + end + + def down do + alter table(:join_requests) do + modify :schema_version, :bigint, null: false + end + end +end diff --git a/test/mv/membership/join_request_test.exs b/test/mv/membership/join_request_test.exs index fa81231..19ab704 100644 --- a/test/mv/membership/join_request_test.exs +++ b/test/mv/membership/join_request_test.exs @@ -7,21 +7,20 @@ defmodule Mv.Membership.JoinRequestTest do """ use Mv.DataCase, async: false + alias Mv.Helpers.SystemActor alias Mv.Membership alias Mv.Membership.JoinRequest require Ash.Query - # Minimal valid attributes for the public :confirm action (per concept §2.3.2) + # Client-only attributes for :confirm (server sets status, submitted_at, source, schema_version) defp valid_confirm_attrs(opts \\ []) do - token = Keyword.get(opts, :confirmation_token_hash, "hash_#{System.unique_integer([:positive])}") + token = + Keyword.get(opts, :confirmation_token_hash, "hash_#{System.unique_integer([:positive])}") + [ email: "join_#{System.unique_integer([:positive])}@example.com", confirmation_token_hash: token, - status: "submitted", - submitted_at: DateTime.utc_now(), - source: "public_join", - schema_version: 1, payload: %{} ] |> Enum.into(%{}) @@ -39,19 +38,36 @@ defmodule Mv.Membership.JoinRequestTest do assert request.source == "public_join" end - test "read with actor nil succeeds for created join request" do + test "no public read: actor nil cannot read JoinRequest (by id or list)" do attrs = valid_confirm_attrs() {:ok, created} = Membership.confirm_join_request(attrs, actor: nil) - assert {:ok, %JoinRequest{} = read} = - Ash.get(JoinRequest, created.id, actor: nil, domain: Mv.Membership) + get_result = Ash.get(JoinRequest, created.id, actor: nil, domain: Mv.Membership) - assert read.id == created.id - assert read.email == created.email + assert match?({:error, %Ash.Error.Forbidden{}}, get_result) or + match?( + {:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{}]}}, + get_result + ) + + list_result = JoinRequest |> Ash.read(actor: nil, domain: Mv.Membership) + + assert match?({:error, %Ash.Error.Forbidden{}}, list_result) or + match?({:error, %Ash.Error.Invalid{}}, list_result) or + list_result == {:ok, []}, + "actor nil must not see any JoinRequests: got #{inspect(list_result)}" end test "generic create with actor nil is forbidden" do - attrs = valid_confirm_attrs() + # Use full attrs required by :create so the only failure is policy, not validation + attrs = + valid_confirm_attrs() + |> Map.merge(%{ + status: "submitted", + submitted_at: DateTime.utc_now(), + source: "public_join", + schema_version: 1 + }) assert {:error, %Ash.Error.Forbidden{errors: [%Ash.Error.Forbidden.Policy{}]}} = JoinRequest @@ -62,25 +78,23 @@ defmodule Mv.Membership.JoinRequestTest do describe "Idempotency (confirmation_token_hash)" do test "second create with same confirmation_token_hash does not create duplicate" do - system_actor = Mv.Helpers.SystemActor.get_system_actor() + system_actor = SystemActor.get_system_actor() token = "idempotent_token_#{System.unique_integer([:positive])}" attrs1 = valid_confirm_attrs(confirmation_token_hash: token) attrs2 = valid_confirm_attrs(confirmation_token_hash: token) attrs2 = %{attrs2 | email: "other_#{System.unique_integer([:positive])}@example.com"} - assert {:ok, first} = Membership.confirm_join_request(attrs1, actor: nil) + assert {:ok, _first} = Membership.confirm_join_request(attrs1, actor: nil) - # Second call with same token: idempotent return {:ok, existing} (concept §2.3.2) - assert {:ok, second} = Membership.confirm_join_request(attrs2, actor: nil) - assert second.id == first.id, "idempotent confirm must return the existing record" + # Second call with same token: idempotent return {:ok, nil} (no public read) + assert {:ok, nil} = Membership.confirm_join_request(attrs2, actor: nil) - count = - JoinRequest - |> Ash.Query.filter(confirmation_token_hash == ^token) - |> Ash.read!(actor: system_actor, domain: Mv.Membership, authorize?: false) - |> length() + # Count via allowed admin read (no authorize?: false) + assert {:ok, list} = Membership.list_join_requests(actor: system_actor) + count = Enum.count(list, &(&1.confirmation_token_hash == token)) - assert count == 1, "expected exactly one JoinRequest with this confirmation_token_hash, got #{count}" + assert count == 1, + "expected exactly one JoinRequest with this confirmation_token_hash, got #{count}" end end -- 2.47.2