Merge pull request 'add join request resource' (#463) from feature/308-web-form into main
All checks were successful
continuous-integration/drone/push Build is passing

Reviewed-on: #463
This commit is contained in:
simon 2026-03-10 10:11:52 +01:00
commit dbd7afbaf4
34 changed files with 1529 additions and 55 deletions

View file

@ -85,6 +85,8 @@ lib/
├── membership/ # Membership domain
│ ├── membership.ex # Domain definition
│ ├── member.ex # Member resource
│ ├── join_request.ex # JoinRequest (public join form, double opt-in)
│ ├── join_request/ # JoinRequest changes (SetConfirmationToken, ConfirmRequest)
│ ├── custom_field.ex # Custom field (definition) resource
│ ├── custom_field_value.ex # Custom field value resource
│ ├── setting.ex # Global settings (singleton resource)
@ -1253,32 +1255,32 @@ mix deps.update phoenix
mix hex.outdated
```
### 3.11 Email: Swoosh
### 3.11 Email: Swoosh and Phoenix.Swoosh
**Mailer Configuration:**
**Mailer and from address:**
- `Mv.Mailer` (Swoosh) and `Mv.Mailer.mail_from/0` return the configured sender `{name, email}`.
- Config: `config :mv, :mail_from, {"Mila", "noreply@example.com"}` in config.exs. In production, runtime.exs overrides from ENV (`MAIL_FROM_NAME`, `MAIL_FROM_EMAIL`).
**Unified layout (transactional emails):**
- All transactional emails (join confirmation, user confirmation, password reset) use the same layout: `MvWeb.EmailLayoutView` (layout) and `MvWeb.EmailsView` (body templates).
- Templates live under `lib/mv_web/templates/emails/` (bodies) and `lib/mv_web/templates/emails/layouts/` (layout). Use Gettext in templates for i18n.
- See `MvWeb.Emails.JoinConfirmationEmail`, `Mv.Accounts.User.Senders.SendNewUserConfirmationEmail`, `SendPasswordResetEmail` for the pattern; see `docs/email-layout-mockup.md` for layout structure.
**Sending with layout:**
```elixir
defmodule Mv.Mailer do
use Swoosh.Mailer, otp_app: :mv
end
```
use Phoenix.Swoosh, view: MvWeb.EmailsView, layout: {MvWeb.EmailLayoutView, "layout.html"}
**Sending Emails:**
```elixir
defmodule Mv.Accounts.WelcomeEmail do
use Phoenix.Swoosh, template_root: "lib/mv_web/templates"
import Swoosh.Email
def send(user) do
new()
|> to({user.name, user.email})
|> from({"Mila", "noreply@mila.example.com"})
|> subject("Welcome to Mila!")
|> render_body("welcome.html", %{user: user})
|> Mv.Mailer.deliver()
end
end
|> from(Mailer.mail_from())
|> to(email_address)
|> subject(gettext("Subject"))
|> put_view(MvWeb.EmailsView)
|> put_layout({MvWeb.EmailLayoutView, "layout.html"})
|> render_body("template_name.html", %{assigns})
|> Mailer.deliver!()
```
### 3.12 Internationalization: Gettext

View file

@ -89,6 +89,10 @@ config :mv, MvWeb.Endpoint,
# at the `config/runtime.exs`.
config :mv, Mv.Mailer, adapter: Swoosh.Adapters.Local
# Default mail "from" address for transactional emails (join confirmation,
# user confirmation, password reset). Override in config/runtime.exs from ENV.
config :mv, :mail_from, {"Mila", "noreply@example.com"}
# Configure esbuild (the version is required)
config :esbuild,
version: "0.17.11",

View file

@ -217,9 +217,13 @@ if config_env() == :prod do
#
# Check `Plug.SSL` for all available options in `force_ssl`.
# ## Configuring the mailer
#
# In production you need to configure the mailer to use a different adapter.
# Transactional emails use the sender from config :mv, :mail_from (overridable via ENV).
config :mv,
:mail_from,
{System.get_env("MAIL_FROM_NAME", "Mila"),
System.get_env("MAIL_FROM_EMAIL", "noreply@example.com")}
# In production you may need to configure the mailer to use a different adapter.
# Also, you may need to configure the Swoosh API client of your choice if you
# are not using SMTP. Here is an example of the configuration:
#

View file

@ -792,6 +792,23 @@ defmodule MvWeb.Components.SearchBarTest do
end
```
### Onboarding / Join (Issue #308, TDD)
**Subtask 1 JoinRequest resource and public policies (done):**
- Resource: `Mv.Membership.JoinRequest` with attributes (status, email, first_name, last_name, form_data, schema_version, confirmation_token_hash, confirmation_token_expires_at, submitted_at, etc.), actions `submit` (create), `get_by_confirmation_token_hash` (read), `confirm` (update). Migration: `20260309141437_add_join_requests.exs`.
- Policies: Public actions allowed with `actor: nil` via `Mv.Authorization.Checks.ActorIsNil` (submit, get_by_confirmation_token_hash, confirm); default read remains Forbidden for unauthenticated.
- Domain: `Mv.Membership.submit_join_request/2`, `Mv.Membership.confirm_join_request/2` (token hashing via `JoinRequest.hash_confirmation_token/1`, lookup, expiry check, idempotency for :submitted/:approved/:rejected).
- Test file: `test/membership/join_request_test.exs` all tests pass; policy test and expired-token test implemented.
**Subtask 2 Submit and confirm flow (done):**
- **Unified email layout:** phoenix_swoosh with `MvWeb.EmailLayoutView` (layout) and `MvWeb.EmailsView` (body templates). All transactional emails (join confirmation, user confirmation, password reset) use the same layout. Config: `config :mv, :mail_from, {name, email}` (default `{"Mila", "noreply@example.com"}`); override in runtime.exs.
- **Join confirmation:** Domain wrapper `submit_join_request/2` generates token (or uses optional `:confirmation_token` in attrs for tests), creates JoinRequest via action `:submit`, then sends one email via `MvWeb.Emails.JoinConfirmationEmail`. Route `GET /confirm_join/:token` (JoinConfirmController) updates to `submitted`; idempotent; expired/invalid handled.
- **Senders migrated:** `SendNewUserConfirmationEmail`, `SendPasswordResetEmail` use layout + `Mv.Mailer.mail_from/0`.
- **Cleanup:** Mix task `mix join_requests.cleanup_expired` hard-deletes JoinRequests in `pending_confirmation` with expired `confirmation_token_expires_at` (authorize?: false). For cron/Oban.
- **Gettext:** New email strings in default domain; German translations in de/LC_MESSAGES/default.po; English msgstr filled for email-related strings.
- **PR review follow-ups (Join confirmation):** Join confirmation email uses `Mailer.deliver/1` and returns `{:ok, email}` \| `{:error, reason}`; domain logs delivery errors but still returns `{:ok, request}` so the user sees success. Comment in `submit_join_request/2` clarifies that the raw token is hashed by `JoinRequest.Changes.SetConfirmationToken`. Cleanup task uses `Ash.bulk_destroy` and logs partial errors without halting. Layout uses assigns `app_name` and `locale` (from config/Gettext) instead of hardcoded "Mila" and `lang="de"`. Production `runtime.exs` sets `:mail_from` from ENV (`MAIL_FROM_NAME`, `MAIL_FROM_EMAIL`). Layout reference unified to `"layout.html"`; redundant `put_layout` removed from senders.
- Tests: `join_request_test.exs`, `join_request_submit_email_test.exs`, `join_confirm_controller_test.exs` all pass.
### Test Data Management
**Seed Data:**

View file

@ -0,0 +1,26 @@
# Unified Email Layout ASCII Mockup
All transactional emails (join confirmation, user confirmation, password reset) use the same layout.
```
+------------------------------------------------------------------+
| [Logo or app name e.g. "Mila" or club name] |
+------------------------------------------------------------------+
| |
| [Subject / heading line e.g. "Confirm your email address"] |
| |
| [Body content paragraph and CTA link] |
| e.g. "Please click the link below to confirm your request." |
| "Confirm my request" (button or link) |
| |
| [Optional: short note e.g. "If you didn't request this, |
| you can ignore this email."] |
| |
+------------------------------------------------------------------+
| [Footer one line, e.g. "© 2025 Mila · Mitgliederverwaltung"] |
+------------------------------------------------------------------+
```
- **Header:** Single line (app/club name), subtle.
- **Main:** Heading + body text + primary CTA (link/button).
- **Footer:** Single line, small text (copyright / product name).

View file

@ -0,0 +1,230 @@
# 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**: the JoinRequest record is **created in the database on form submit** in status `pending_confirmation`, then **updated to** `submitted` after the user clicks the confirmation link.
- This keeps:
- **Public intake** (abuse-prone) separate from **identity and account creation** (after approval / invite / OIDC).
- Existing policies (e.g. UserMember 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)
1. Unauthenticated user opens `/join`.
2. Short explanation + form (what happens next: "We will review … you will hear from us").
3. **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."
4. **User clicks confirmation link** → The existing JoinRequest is **updated** to status `submitted` (`submitted_at` set, 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 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.
- **On form submit:** **Create** a JoinRequest with status `pending_confirmation`, store confirmation token **hash** in the DB (raw token only in the email link), set `confirmation_token_expires_at` (e.g. 24h), store all allowlisted form data (see §2.3.2), then send confirmation email.
- **On confirmation link click:** **Update** the JoinRequest (find by token hash): set status to `submitted`, set `submitted_at`, clear/invalidate token fields. If the record is already `submitted`, 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_confirmation` past 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) → later `approved` / `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).
- **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:** `/join` and the confirmation route must be public (unauthenticated access returns 200).
- **Explicit public path for `/join`:** Add **`/join`** (and if needed `/join/*`) to the page-permission plugs **`public_path?/1`** so that the join page is reachable without login. Do not rely on the confirm path alone.
- **Confirmation route:** Use **`/confirm_join/:token`** so that the existing whitelist (e.g. `String.starts_with?(path, "/confirm")`) already covers it; no extra plug change for confirm.
- **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). 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. `submit` for create, `confirm` for update). Model via **policies** that permit these actions when actor is nil; do **not** use `authorize?: false` unless documented and clearly not a privilege-escalation path.
- **No system-actor fallback:** Join and confirmation run without an authenticated user. Do **not** use the system actor as a fallback for "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 `/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). 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.
- **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 the approval page (e.g. `/join_requests` or `/onboarding/join_requests`) is added to normal_users allowed pages.
---
## 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** (status `pending_confirmation`) and **updated to** `submitted` on confirmation. Abuse surface and policy complexity stay low.
- **Dedicated resource and actions:** New resource `JoinRequest` with public actions: **submit** (create with `pending_confirmation` + send email) and **confirm** (update to `submitted`). Member/User domain unchanged.
- **Public paths:** `/join` is **explicitly** added to the page-permission plugs public path list; confirmation route `/confirm_join/:token` is 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 to `submitted`; 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_confirmation` and **updated to** `submitted` when 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/:token`** so existing `starts_with?(path, "/confirm")` covers it.
- **Public path for `/join`:** **Add `/join` explicitly** to the page-permission plugs `public_path?/1` (e.g. in `CheckPagePermission`) 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 is `pending_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_enabled` plus 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.
- **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 `/join` page and confirmation route reachable without login; **`/join` explicitly** in public paths (plug and tests).
- Flow: form submit → **JoinRequest created** in status `pending_confirmation` → confirmation email sent → user clicks link → **JoinRequest updated** to status `submitted`; 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_confirmation` older than 24h (or configured retention).
- Page-permission and routing tests updated (including public-path coverage for `/join` and `/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
- **Scope:** Ash resource `JoinRequest` per §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 undocumented `authorize?: 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: nil` and for idempotent confirm (same token twice → no second update).
#### 2. Submit and confirm flow
- **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 status `submitted`, set submitted_at, invalidate token (idempotent if already submitted). Optional: Oban (or similar) job to **hard-delete** JoinRequests in `pending_confirmation` with 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
- **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
- **Scope:** Route **`/join`** (public). **Add `/join` to the page-permission plugs 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)
- **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.
- `lib/mv_web/plugs/check_page_permission.ex` Public path list; **add `/join`** in `public_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.

View file

@ -38,6 +38,8 @@ This document lists all protected routes, which permission set may access them,
- `/auth*`, `/register`, `/reset`, `/sign-in`, `/sign-out`, `/confirm*`, `/password-reset*`, `/set_locale`
The join confirmation route `GET /confirm_join/:token` is public (matched by `/confirm*`). Unit tests: `test/mv_web/controllers/join_confirm_controller_test.exs` (stubbed callback, no integration).
## Test Coverage
**File:** `test/mv_web/plugs/check_page_permission_test.exs`

View file

@ -0,0 +1,147 @@
defmodule Mv.Membership.JoinRequest do
@moduledoc """
Ash resource for public join requests (onboarding, double opt-in).
A JoinRequest is created on form submit with status `pending_confirmation`, then
updated to `submitted` when the user clicks the confirmation link. No User or
Member is created in this flow; promotion happens in a later approval step.
## Public actions (actor: nil)
- `submit` (create) create with token hash and expiry
- `get_by_confirmation_token_hash` (read) lookup by token hash for confirm flow
- `confirm` (update) set status to submitted and invalidate token
## Schema
Typed: email (required), first_name, last_name. Remaining form data in form_data (jsonb).
Confirmation: confirmation_token_hash, confirmation_token_expires_at. Audit: submitted_at, etc.
"""
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 :submit do
description "Create a join request (public form submit); stores token hash and expiry"
primary? true
argument :confirmation_token, :string, allow_nil?: false
accept [:email, :first_name, :last_name, :form_data, :schema_version]
change Mv.Membership.JoinRequest.Changes.SetConfirmationToken
end
read :get_by_confirmation_token_hash do
description "Find a join request by confirmation token hash (for confirm flow only)"
argument :confirmation_token_hash, :string, allow_nil?: false
filter expr(confirmation_token_hash == ^arg(:confirmation_token_hash))
prepare build(sort: [inserted_at: :desc], limit: 1)
end
update :confirm do
description "Mark join request as submitted and invalidate token (after link click)"
primary? true
require_atomic? false
change Mv.Membership.JoinRequest.Changes.ConfirmRequest
end
end
policies do
policy action(:submit) do
description "Allow unauthenticated submit (public join form)"
authorize_if Mv.Authorization.Checks.ActorIsNil
end
policy action(:get_by_confirmation_token_hash) do
description "Allow unauthenticated lookup by token hash for confirm"
authorize_if Mv.Authorization.Checks.ActorIsNil
end
policy action(:confirm) do
description "Allow unauthenticated confirm (confirmation link click)"
authorize_if Mv.Authorization.Checks.ActorIsNil
end
# Default read/destroy: no policy for actor nil → Forbidden
end
validations do
validate present(:email), on: [:create]
end
# Attributes are backend-internal for now; set public? true when exposing via AshJsonApi/AshGraphql
attributes do
uuid_primary_key :id
attribute :status, :atom do
description "pending_confirmation | submitted | approved | rejected"
default :pending_confirmation
constraints one_of: [:pending_confirmation, :submitted, :approved, :rejected]
allow_nil? false
end
attribute :email, :string do
description "Email address (required for join form)"
allow_nil? false
end
attribute :first_name, :string
attribute :last_name, :string
attribute :form_data, :map do
description "Additional form fields (jsonb)"
end
attribute :schema_version, :integer do
description "Version of join form / member_fields for form_data"
end
attribute :confirmation_token_hash, :string do
description "SHA256 hash of confirmation token; raw token only in email link"
end
attribute :confirmation_token_expires_at, :utc_datetime_usec do
description "When the confirmation link expires (e.g. 24h)"
end
attribute :confirmation_sent_at, :utc_datetime_usec do
description "When the confirmation email was sent"
end
attribute :submitted_at, :utc_datetime_usec do
description "When the user confirmed (clicked the link)"
end
attribute :approved_at, :utc_datetime_usec
attribute :rejected_at, :utc_datetime_usec
attribute :reviewed_by_user_id, :uuid
attribute :source, :string
create_timestamp :inserted_at
update_timestamp :updated_at
end
# Public helpers (used by SetConfirmationToken change and domain confirm_join_request)
@doc """
Returns the SHA256 hash of the confirmation token (lowercase hex).
Used when creating a join request (submit) and when confirming by token.
Only one implementation ensures algorithm changes stay in sync.
"""
@spec hash_confirmation_token(String.t()) :: String.t()
def hash_confirmation_token(token) when is_binary(token) do
:crypto.hash(:sha256, token) |> Base.encode16(case: :lower)
end
end

View file

@ -0,0 +1,25 @@
defmodule Mv.Membership.JoinRequest.Changes.ConfirmRequest do
@moduledoc """
Sets the join request to submitted (confirmation link clicked).
Used by the confirm action after the user clicks the confirmation link.
Only applies when the current status is `:pending_confirmation`, so that
direct calls to the confirm action are idempotent and never overwrite
:submitted, :approved, or :rejected. Token hash is kept so a second click
can still find the record and return success without changing it.
"""
use Ash.Resource.Change
@spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t()
def change(changeset, _opts, _context) do
current_status = Ash.Changeset.get_data(changeset, :status)
if current_status == :pending_confirmation do
changeset
|> Ash.Changeset.force_change_attribute(:status, :submitted)
|> Ash.Changeset.force_change_attribute(:submitted_at, DateTime.utc_now())
else
changeset
end
end
end

View file

@ -0,0 +1,32 @@
defmodule Mv.Membership.JoinRequest.Changes.SetConfirmationToken do
@moduledoc """
Hashes the confirmation token and sets expiry for the join request (submit flow).
Uses `JoinRequest.hash_confirmation_token/1` so hashing logic lives in one place.
Reads the :confirmation_token argument, stores only its SHA256 hash and sets
confirmation_token_expires_at (e.g. 24h). Raw token is never persisted.
"""
use Ash.Resource.Change
alias Mv.Membership.JoinRequest
@confirmation_validity_hours 24
@spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t()
def change(changeset, _opts, _context) do
token = Ash.Changeset.get_argument(changeset, :confirmation_token)
if is_binary(token) and token != "" do
hash = JoinRequest.hash_confirmation_token(token)
expires_at = DateTime.utc_now() |> DateTime.add(@confirmation_validity_hours, :hour)
changeset
|> Ash.Changeset.force_change_attribute(:confirmation_token_hash, hash)
|> Ash.Changeset.force_change_attribute(:confirmation_token_expires_at, expires_at)
|> Ash.Changeset.force_change_attribute(:status, :pending_confirmation)
else
changeset
end
end
end

View file

@ -9,6 +9,7 @@ defmodule Mv.Membership do
- `Setting` - Global application settings (singleton)
- `Group` - Groups that members can belong to
- `MemberGroup` - Join table for many-to-many relationship between Members and Groups
- `JoinRequest` - Public join form submissions (pending_confirmation submitted after email confirm)
## Public API
The domain exposes these main actions:
@ -27,6 +28,10 @@ defmodule Mv.Membership do
require Ash.Query
import Ash.Expr
alias Ash.Error.Query.NotFound, as: NotFoundError
alias Mv.Membership.JoinRequest
alias MvWeb.Emails.JoinConfirmationEmail
require Logger
admin do
show? true
@ -80,6 +85,10 @@ defmodule Mv.Membership do
define :list_member_groups, action: :read
define :destroy_member_group, action: :destroy
end
resource Mv.Membership.JoinRequest do
# submit_join_request/2 implemented as custom function below (create + send email)
end
end
# Singleton pattern: Get the single settings record
@ -342,4 +351,110 @@ defmodule Mv.Membership do
|> Keyword.put_new(:domain, __MODULE__)
|> then(&Ash.read_one(query, &1))
end
@doc """
Creates a join request (submit flow) and sends the confirmation email.
Generates a confirmation token if not provided in attrs (e.g. for tests, pass
`:confirmation_token` to get a known token). On success, sends one email with
the confirm link to the request email.
## Options
- `:actor` - Must be nil for public submit (policy allows only unauthenticated).
## Returns
- `{:ok, request}` - Created JoinRequest in status pending_confirmation
- `{:error, error}` - Validation or authorization error
"""
def submit_join_request(attrs, opts \\ []) do
actor = Keyword.get(opts, :actor)
token = Map.get(attrs, :confirmation_token) || generate_confirmation_token()
# Raw token is passed to the submit action; JoinRequest.Changes.SetConfirmationToken
# hashes it before persist. Only the hash is stored; the raw token is sent in the email link.
attrs_with_token = Map.put(attrs, :confirmation_token, token)
case Ash.create(JoinRequest, attrs_with_token,
action: :submit,
actor: actor,
domain: __MODULE__
) do
{:ok, request} ->
case JoinConfirmationEmail.send(request.email, token) do
{:ok, _email} ->
{:ok, request}
{:error, reason} ->
Logger.error(
"Join confirmation email failed for #{request.email}: #{inspect(reason)}"
)
# Request was created; return success so the user sees the confirmation message
{:ok, request}
end
error ->
error
end
end
defp generate_confirmation_token do
32
|> :crypto.strong_rand_bytes()
|> Base.url_encode64(padding: false)
end
@doc """
Confirms a join request by token (public confirmation link).
Hashes the token, finds the JoinRequest by confirmation_token_hash, checks that
the token has not expired, then updates to status :submitted. Idempotent: if
already submitted, approved, or rejected, returns the existing record without changing it.
## Options
- `:actor` - Must be nil for public confirm (policy allows only unauthenticated).
## Returns
- `{:ok, request}` - Updated or already-processed JoinRequest
- `{:error, :token_expired}` - Token was found but confirmation_token_expires_at is in the past
- `{:error, error}` - Token unknown/invalid or authorization error
"""
def confirm_join_request(token, opts \\ []) when is_binary(token) do
hash = JoinRequest.hash_confirmation_token(token)
actor = Keyword.get(opts, :actor)
query =
Ash.Query.for_read(JoinRequest, :get_by_confirmation_token_hash, %{
confirmation_token_hash: hash
})
case Ash.read_one(query, actor: actor, domain: __MODULE__) do
{:ok, nil} ->
{:error, NotFoundError.exception(resource: JoinRequest)}
{:ok, request} ->
do_confirm_request(request, actor)
{:error, error} ->
{:error, error}
end
end
defp do_confirm_request(request, _actor)
when request.status in [:submitted, :approved, :rejected] do
{:ok, request}
end
defp do_confirm_request(request, actor) do
if expired?(request.confirmation_token_expires_at) do
{:error, :token_expired}
else
request
|> Ash.Changeset.for_update(:confirm, %{}, domain: __MODULE__)
|> Ash.update(domain: __MODULE__, actor: actor)
end
end
defp expired?(nil), do: true
defp expired?(expires_at), do: DateTime.compare(expires_at, DateTime.utc_now()) == :lt
end

View file

@ -0,0 +1,75 @@
defmodule Mix.Tasks.JoinRequests.CleanupExpired do
@moduledoc """
Hard-deletes JoinRequests in status `pending_confirmation` whose confirmation link has expired.
Retention: records with `confirmation_token_expires_at` older than now are deleted.
Intended for cron or Oban (e.g. every hour). See docs/onboarding-join-concept.md.
## Usage
mix join_requests.cleanup_expired
## Examples
$ mix join_requests.cleanup_expired
Deleted 3 expired join request(s).
"""
use Mix.Task
require Ash.Query
require Logger
alias Mv.Membership.JoinRequest
@shortdoc "Deletes join requests in pending_confirmation with expired confirmation token"
@impl Mix.Task
def run(_args) do
Mix.Task.run("app.start")
now = DateTime.utc_now()
query =
JoinRequest
|> Ash.Query.filter(status == :pending_confirmation)
|> Ash.Query.filter(confirmation_token_expires_at < ^now)
# Bypass authorization: cleanup is a system maintenance task (cron/Oban).
# Use bulk_destroy so the data layer can delete in one pass when supported.
opts = [domain: Mv.Membership, authorize?: false]
count =
case Ash.count(query, opts) do
{:ok, n} -> n
{:error, _} -> 0
end
do_run(query, opts, count)
end
defp do_run(_query, _opts, 0) do
Mix.shell().info("No expired join requests to delete.")
0
end
defp do_run(query, opts, count) do
case Ash.bulk_destroy(query, :destroy, %{}, opts) do
%{status: status, errors: errors} when status in [:success, :partial_success] ->
maybe_log_errors(errors)
Mix.shell().info("Deleted #{count} expired join request(s).")
count
%{status: :error, errors: errors} ->
Mix.raise("Failed to delete expired join requests: #{inspect(errors)}")
end
end
defp maybe_log_errors(nil), do: :ok
defp maybe_log_errors([]), do: :ok
defp maybe_log_errors(errors) do
Logger.warning(
"Join requests cleanup: #{length(errors)} error(s) while deleting expired requests: #{inspect(errors)}"
)
end
end

View file

@ -1,12 +1,20 @@
defmodule Mv.Accounts.User.Senders.SendNewUserConfirmationEmail do
@moduledoc """
Sends an email for a new user to confirm their email address.
Uses the unified email layout (MvWeb.EmailsView + EmailLayoutView) and
central mail from config (Mv.Mailer.mail_from/0).
"""
use AshAuthentication.Sender
use MvWeb, :verified_routes
use Phoenix.Swoosh,
view: MvWeb.EmailsView,
layout: {MvWeb.EmailLayoutView, "layout.html"}
use MvWeb, :verified_routes
import Swoosh.Email
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
alias Mv.Mailer
@ -26,21 +34,22 @@ defmodule Mv.Accounts.User.Senders.SendNewUserConfirmationEmail do
"""
@impl true
def send(user, token, _) do
confirm_url = url(~p"/confirm_new_user/#{token}")
subject = gettext("Confirm your email address")
assigns = %{
confirm_url: confirm_url,
subject: subject,
app_name: Mailer.mail_from() |> elem(0),
locale: Gettext.get_locale(MvWeb.Gettext)
}
new()
# Replace with email from env
|> from({"noreply", "noreply@example.com"})
|> from(Mailer.mail_from())
|> to(to_string(user.email))
|> subject("Confirm your email address")
|> html_body(body(token: token))
|> subject(subject)
|> put_view(MvWeb.EmailsView)
|> render_body("user_confirmation.html", assigns)
|> Mailer.deliver!()
end
defp body(params) do
url = url(~p"/confirm_new_user/#{params[:token]}")
"""
<p>Click this link to confirm your email:</p>
<p><a href="#{url}">#{url}</a></p>
"""
end
end

View file

@ -1,12 +1,20 @@
defmodule Mv.Accounts.User.Senders.SendPasswordResetEmail do
@moduledoc """
Sends a password reset email
Sends a password reset email.
Uses the unified email layout (MvWeb.EmailsView + EmailLayoutView) and
central mail from config (Mv.Mailer.mail_from/0).
"""
use AshAuthentication.Sender
use MvWeb, :verified_routes
use Phoenix.Swoosh,
view: MvWeb.EmailsView,
layout: {MvWeb.EmailLayoutView, "layout.html"}
use MvWeb, :verified_routes
import Swoosh.Email
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
alias Mv.Mailer
@ -26,21 +34,22 @@ defmodule Mv.Accounts.User.Senders.SendPasswordResetEmail do
"""
@impl true
def send(user, token, _) do
reset_url = url(~p"/password-reset/#{token}")
subject = gettext("Reset your password")
assigns = %{
reset_url: reset_url,
subject: subject,
app_name: Mailer.mail_from() |> elem(0),
locale: Gettext.get_locale(MvWeb.Gettext)
}
new()
# Replace with email from env
|> from({"noreply", "noreply@example.com"})
|> from(Mailer.mail_from())
|> to(to_string(user.email))
|> subject("Reset your password")
|> html_body(body(token: token))
|> subject(subject)
|> put_view(MvWeb.EmailsView)
|> render_body("password_reset.html", assigns)
|> Mailer.deliver!()
end
defp body(params) do
url = url(~p"/password-reset/#{params[:token]}")
"""
<p>Click this link to reset your password:</p>
<p><a href="#{url}">#{url}</a></p>
"""
end
end

View file

@ -0,0 +1,17 @@
defmodule Mv.Authorization.Checks.ActorIsNil do
@moduledoc """
Policy check: true only when the actor is nil (unauthenticated).
Used for the public join flow so that submit and confirm actions are allowed
only when called without an authenticated user (e.g. from the public /join form
and confirmation link). See docs/onboarding-join-concept.md.
"""
use Ash.Policy.SimpleCheck
@impl true
def describe(_opts), do: "actor is nil (unauthenticated)"
@impl true
def match?(nil, _context, _opts), do: true
def match?(_actor, _context, _opts), do: false
end

View file

@ -1,3 +1,19 @@
defmodule Mv.Mailer do
@moduledoc """
Swoosh mailer for transactional emails.
Use `mail_from/0` for the configured sender address (join confirmation,
user confirmation, password reset).
"""
use Swoosh.Mailer, otp_app: :mv
@doc """
Returns the configured "from" address for transactional emails.
Configure in config.exs or runtime.exs as `config :mv, :mail_from, {name, email}`.
Default: `{"Mila", "noreply@example.com"}`.
"""
def mail_from do
Application.get_env(:mv, :mail_from, {"Mila", "noreply@example.com"})
end
end

View file

@ -0,0 +1,45 @@
defmodule MvWeb.JoinConfirmController do
@moduledoc """
Handles GET /confirm_join/:token for the public join flow (double opt-in).
Calls a configurable callback (default Mv.Membership) so tests can stub the
dependency. Public route; no authentication required.
"""
use MvWeb, :controller
def confirm(conn, %{"token" => token}) when is_binary(token) do
callback = Application.get_env(:mv, :join_confirm_callback, Mv.Membership)
case callback.confirm_join_request(token, actor: nil) do
{:ok, _request} ->
success_response(conn)
{:error, :token_expired} ->
expired_response(conn)
{:error, _} ->
invalid_response(conn)
end
end
def confirm(conn, _params), do: invalid_response(conn)
defp success_response(conn) do
conn
|> put_resp_content_type("text/html")
|> send_resp(200, gettext("Thank you, we have received your request."))
end
defp expired_response(conn) do
conn
|> put_resp_content_type("text/html")
|> send_resp(200, gettext("This link has expired. Please submit the form again."))
end
defp invalid_response(conn) do
conn
|> put_resp_content_type("text/html")
|> put_status(404)
|> send_resp(404, gettext("Invalid or expired link."))
end
end

View file

@ -0,0 +1,15 @@
defmodule MvWeb.EmailLayoutView do
@moduledoc """
Layout view for transactional emails (join confirmation, user confirmation, password reset).
Renders a single layout template that wraps all email body content.
See docs/email-layout-mockup.md for the layout structure.
Uses Phoenix.View (legacy API) for compatibility with phoenix_swoosh email rendering.
Layout expects assigns :app_name and :locale (passed from each email sender).
"""
use Phoenix.View,
root: "lib/mv_web",
path: "templates/emails/layouts",
namespace: MvWeb
end

View file

@ -0,0 +1,13 @@
defmodule MvWeb.EmailsView do
@moduledoc """
View for transactional email body templates.
Templates are rendered inside EmailLayoutView layout when sent via Phoenix.Swoosh.
"""
use Phoenix.View,
root: "lib/mv_web",
path: "templates/emails",
namespace: MvWeb
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
end

View file

@ -0,0 +1,43 @@
defmodule MvWeb.Emails.JoinConfirmationEmail do
@moduledoc """
Sends the join request confirmation email (double opt-in) using the unified email layout.
"""
use Phoenix.Swoosh,
view: MvWeb.EmailsView,
layout: {MvWeb.EmailLayoutView, "layout.html"}
use MvWeb, :verified_routes
import Swoosh.Email
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
alias Mv.Mailer
@doc """
Sends the join confirmation email to the given address with the confirmation link.
Called from the domain after a JoinRequest is created (submit flow).
Returns `{:ok, email}` on success, `{:error, reason}` on delivery failure.
Callers should log errors and may still return success for the overall operation
(e.g. join request created) so the user is not shown a generic error when only
the email failed.
"""
def send(email_address, token) when is_binary(email_address) and is_binary(token) do
confirm_url = url(~p"/confirm_join/#{token}")
subject = gettext("Confirm your membership request")
assigns = %{
confirm_url: confirm_url,
subject: subject,
app_name: Mailer.mail_from() |> elem(0),
locale: Gettext.get_locale(MvWeb.Gettext)
}
new()
|> from(Mailer.mail_from())
|> to(email_address)
|> subject(subject)
|> put_view(MvWeb.EmailsView)
|> render_body("join_confirmation.html", assigns)
|> Mailer.deliver()
end
end

View file

@ -126,6 +126,9 @@ defmodule MvWeb.Router do
overrides: [MvWeb.AuthOverrides, AshAuthentication.Phoenix.Overrides.DaisyUI],
gettext_backend: {MvWeb.Gettext, "auth"}
# Public join confirmation (double opt-in); /confirm* is already public in CheckPagePermission
get "/confirm_join/:token", JoinConfirmController, :confirm
# Remove this if you do not use the magic link strategy.
# magic_sign_in_route(Mv.Accounts.User, :magic_link,
# auth_routes_prefix: "/auth",

View file

@ -0,0 +1,18 @@
<div style="color: #111827;">
<p style="margin: 0 0 16px; font-size: 16px; line-height: 1.5;">
{gettext(
"We have received your membership request. To complete it, please click the link below."
)}
</p>
<p style="margin: 0 0 24px;">
<a
href={@confirm_url}
style="display: inline-block; padding: 12px 24px; background-color: #2563eb; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 500;"
>
{gettext("Confirm my request")}
</a>
</p>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
{gettext("If you did not submit this request, you can ignore this email.")}
</p>
</div>

View file

@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang={assigns[:locale] || "de"}>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{assigns[:subject] || assigns[:app_name] || "Mila"}</title>
</head>
<body style="margin: 0; padding: 0; font-family: system-ui, -apple-system, sans-serif; background-color: #f3f4f6;">
<table
role="presentation"
width="100%"
cellspacing="0"
cellpadding="0"
style="max-width: 600px; margin: 0 auto;"
>
<tr>
<td style="padding: 24px 16px 16px;">
<div style="font-size: 18px; font-weight: 600; color: #111827;">
{assigns[:app_name] || "Mila"}
</div>
</td>
</tr>
<tr>
<td style="padding: 16px; background-color: #ffffff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.08);">
{@inner_content}
</td>
</tr>
<tr>
<td style="padding: 16px 24px; font-size: 12px; color: #6b7280;">
© {DateTime.utc_now().year} {assigns[:app_name] || "Mila"} · Mitgliederverwaltung
</td>
</tr>
</table>
</body>
</html>

View file

@ -0,0 +1,18 @@
<div style="color: #111827;">
<p style="margin: 0 0 16px; font-size: 16px; line-height: 1.5;">
{gettext("You requested a password reset. Click the link below to set a new password.")}
</p>
<p style="margin: 0 0 24px;">
<a
href={@reset_url}
style="display: inline-block; padding: 12px 24px; background-color: #2563eb; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 500;"
>
{gettext("Reset password")}
</a>
</p>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
{gettext(
"If you did not request this, you can ignore this email. Your password will remain unchanged."
)}
</p>
</div>

View file

@ -0,0 +1,16 @@
<div style="color: #111827;">
<p style="margin: 0 0 16px; font-size: 16px; line-height: 1.5;">
{gettext("Please confirm your email address by clicking the link below.")}
</p>
<p style="margin: 0 0 24px;">
<a
href={@confirm_url}
style="display: inline-block; padding: 12px 24px; background-color: #2563eb; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 500;"
>
{gettext("Confirm my email")}
</a>
</p>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
{gettext("If you did not create an account, you can ignore this email.")}
</p>
</div>

View file

@ -65,6 +65,7 @@ defmodule Mv.MixProject do
app: false,
compile: false,
depth: 1},
{:phoenix_swoosh, "~> 1.0"},
{:swoosh, "~> 1.16"},
{:req, "~> 0.5"},
{:telemetry_metrics, "~> 1.0"},

View file

@ -65,6 +65,7 @@
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.26", "306af67d6557cc01f880107cc459f1fa0acbaab60bc8c027a368ba16b3544473", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0ec34b24c69aa70c4f25a8901effe3462bee6c8ca80a9a4a7685215e3a0ac34e"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
"phoenix_swoosh": {:hex, :phoenix_swoosh, "1.2.1", "b74ccaa8046fbc388a62134360ee7d9742d5a8ae74063f34eb050279de7a99e1", [:mix], [{:finch, "~> 0.8", [hex: :finch, repo: "hexpm", optional: true]}, {:hackney, "~> 1.10", [hex: :hackney, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_view, "~> 1.0 or ~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:swoosh, "~> 1.5", [hex: :swoosh, repo: "hexpm", optional: false]}], "hexpm", "4000eeba3f9d7d1a6bf56d2bd56733d5cadf41a7f0d8ffe5bb67e7d667e204a2"},
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
"phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"},
"picosat_elixir": {:hex, :picosat_elixir, "0.2.3", "bf326d0f179fbb3b706bb2c15fbc367dacfa2517157d090fdfc32edae004c597", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f76c9db2dec9d2561ffaa9be35f65403d53e984e8cd99c832383b7ab78c16c66"},
@ -97,6 +98,6 @@
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
"yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"},
"yaml_elixir": {:hex, :yaml_elixir, "2.12.1", "d74f2d82294651b58dac849c45a82aaea639766797359baff834b64439f6b3f4", [], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "d9ac16563c737d55f9bfeed7627489156b91268a3a21cd55c54eb2e335207fed"},
"yaml_elixir": {:hex, :yaml_elixir, "2.12.1", "d74f2d82294651b58dac849c45a82aaea639766797359baff834b64439f6b3f4", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "d9ac16563c737d55f9bfeed7627489156b91268a3a21cd55c54eb2e335207fed"},
"ymlr": {:hex, :ymlr, "5.1.4", "b924d61e1fc1ec371cde6ab3ccd9311110b1e052fc5c2460fb322e8380e7712a", [:mix], [], "hexpm", "75f16cf0709fbd911b30311a0359a7aa4b5476346c01882addefd5f2b1cfaa51"},
}

View file

@ -3240,6 +3240,81 @@ msgstr[1] "%{count} Filter aktiv"
msgid "without %{name}"
msgstr "ohne %{name}"
#: lib/mv_web/templates/emails/user_confirmation.html.heex
#, elixir-autogen, elixir-format
msgid "Confirm my email"
msgstr "E-Mail bestätigen"
#: lib/mv_web/templates/emails/join_confirmation.html.heex
#, elixir-autogen, elixir-format
msgid "Confirm my request"
msgstr "Anfrage bestätigen"
#: lib/mv/accounts/user/senders/send_new_user_confirmation_email.ex
#, elixir-autogen, elixir-format
msgid "Confirm your email address"
msgstr "E-Mail-Adresse bestätigen"
#: lib/mv_web/emails/join_confirmation_email.ex
#, elixir-autogen, elixir-format
msgid "Confirm your membership request"
msgstr "Mitgliedschaftsanfrage bestätigen"
#: lib/mv_web/templates/emails/user_confirmation.html.heex
#, elixir-autogen, elixir-format
msgid "If you did not create an account, you can ignore this email."
msgstr "Wenn Sie kein Konto angelegt haben, können Sie diese E-Mail ignorieren."
#: lib/mv_web/templates/emails/password_reset.html.heex
#, elixir-autogen, elixir-format
msgid "If you did not request this, you can ignore this email. Your password will remain unchanged."
msgstr "Wenn Sie das nicht angefordert haben, können Sie diese E-Mail ignorieren. Ihr Passwort bleibt unverändert."
#: lib/mv_web/templates/emails/join_confirmation.html.heex
#, elixir-autogen, elixir-format
msgid "If you did not submit this request, you can ignore this email."
msgstr "Wenn Sie diese Anfrage nicht gestellt haben, können Sie diese E-Mail ignorieren."
#: lib/mv_web/controllers/join_confirm_controller.ex
#, elixir-autogen, elixir-format
msgid "Invalid or expired link."
msgstr "Ungültiger oder abgelaufener Link."
#: lib/mv_web/templates/emails/user_confirmation.html.heex
#, elixir-autogen, elixir-format
msgid "Please confirm your email address by clicking the link below."
msgstr "Bitte bestätigen Sie Ihre E-Mail-Adresse, indem Sie auf den folgenden Link klicken."
#: lib/mv_web/templates/emails/password_reset.html.heex
#, elixir-autogen, elixir-format
msgid "Reset password"
msgstr "Passwort zurücksetzen"
#: lib/mv/accounts/user/senders/send_password_reset_email.ex
#, elixir-autogen, elixir-format
msgid "Reset your password"
msgstr "Passwort zurücksetzen"
#: lib/mv_web/controllers/join_confirm_controller.ex
#, elixir-autogen, elixir-format
msgid "Thank you, we have received your request."
msgstr "Vielen Dank, wir haben Ihre Anfrage erhalten."
#: lib/mv_web/controllers/join_confirm_controller.ex
#, elixir-autogen, elixir-format
msgid "This link has expired. Please submit the form again."
msgstr "Dieser Link ist abgelaufen. Bitte senden Sie das Formular erneut ab."
#: lib/mv_web/templates/emails/join_confirmation.html.heex
#, elixir-autogen, elixir-format
msgid "We have received your membership request. To complete it, please click the link below."
msgstr "Wir haben Ihre Mitgliedschaftsanfrage erhalten. Bitte klicken Sie zur Bestätigung auf den folgenden Link."
#: lib/mv_web/templates/emails/password_reset.html.heex
#, elixir-autogen, elixir-format
msgid "You requested a password reset. Click the link below to set a new password."
msgstr "Sie haben die Zurücksetzung Ihres Passworts angefordert. Klicken Sie auf den folgenden Link, um ein neues Passwort zu setzen."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Incomplete"

View file

@ -3240,6 +3240,81 @@ msgstr[1] ""
msgid "without %{name}"
msgstr ""
#: lib/mv_web/templates/emails/user_confirmation.html.heex
#, elixir-autogen, elixir-format
msgid "Confirm my email"
msgstr ""
#: lib/mv_web/templates/emails/join_confirmation.html.heex
#, elixir-autogen, elixir-format
msgid "Confirm my request"
msgstr ""
#: lib/mv/accounts/user/senders/send_new_user_confirmation_email.ex
#, elixir-autogen, elixir-format
msgid "Confirm your email address"
msgstr ""
#: lib/mv_web/emails/join_confirmation_email.ex
#, elixir-autogen, elixir-format
msgid "Confirm your membership request"
msgstr ""
#: lib/mv_web/templates/emails/user_confirmation.html.heex
#, elixir-autogen, elixir-format
msgid "If you did not create an account, you can ignore this email."
msgstr ""
#: lib/mv_web/templates/emails/password_reset.html.heex
#, elixir-autogen, elixir-format
msgid "If you did not request this, you can ignore this email. Your password will remain unchanged."
msgstr ""
#: lib/mv_web/templates/emails/join_confirmation.html.heex
#, elixir-autogen, elixir-format
msgid "If you did not submit this request, you can ignore this email."
msgstr ""
#: lib/mv_web/controllers/join_confirm_controller.ex
#, elixir-autogen, elixir-format
msgid "Invalid or expired link."
msgstr ""
#: lib/mv_web/templates/emails/user_confirmation.html.heex
#, elixir-autogen, elixir-format
msgid "Please confirm your email address by clicking the link below."
msgstr ""
#: lib/mv_web/templates/emails/password_reset.html.heex
#, elixir-autogen, elixir-format
msgid "Reset password"
msgstr ""
#: lib/mv/accounts/user/senders/send_password_reset_email.ex
#, elixir-autogen, elixir-format
msgid "Reset your password"
msgstr ""
#: lib/mv_web/controllers/join_confirm_controller.ex
#, elixir-autogen, elixir-format
msgid "Thank you, we have received your request."
msgstr ""
#: lib/mv_web/controllers/join_confirm_controller.ex
#, elixir-autogen, elixir-format
msgid "This link has expired. Please submit the form again."
msgstr ""
#: lib/mv_web/templates/emails/join_confirmation.html.heex
#, elixir-autogen, elixir-format
msgid "We have received your membership request. To complete it, please click the link below."
msgstr ""
#: lib/mv_web/templates/emails/password_reset.html.heex
#, elixir-autogen, elixir-format
msgid "You requested a password reset. Click the link below to set a new password."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Incomplete"

View file

@ -3240,6 +3240,81 @@ msgstr[1] "%{count} filters active"
msgid "without %{name}"
msgstr "without %{name}"
#: lib/mv_web/templates/emails/user_confirmation.html.heex
#, elixir-autogen, elixir-format
msgid "Confirm my email"
msgstr "Confirm my email"
#: lib/mv_web/templates/emails/join_confirmation.html.heex
#, elixir-autogen, elixir-format
msgid "Confirm my request"
msgstr "Confirm my request"
#: lib/mv/accounts/user/senders/send_new_user_confirmation_email.ex
#, elixir-autogen, elixir-format
msgid "Confirm your email address"
msgstr "Confirm your email address"
#: lib/mv_web/emails/join_confirmation_email.ex
#, elixir-autogen, elixir-format
msgid "Confirm your membership request"
msgstr "Confirm your membership request"
#: lib/mv_web/templates/emails/user_confirmation.html.heex
#, elixir-autogen, elixir-format
msgid "If you did not create an account, you can ignore this email."
msgstr "If you did not create an account, you can ignore this email."
#: lib/mv_web/templates/emails/password_reset.html.heex
#, elixir-autogen, elixir-format
msgid "If you did not request this, you can ignore this email. Your password will remain unchanged."
msgstr "If you did not request this, you can ignore this email. Your password will remain unchanged."
#: lib/mv_web/templates/emails/join_confirmation.html.heex
#, elixir-autogen, elixir-format
msgid "If you did not submit this request, you can ignore this email."
msgstr "If you did not submit this request, you can ignore this email."
#: lib/mv_web/controllers/join_confirm_controller.ex
#, elixir-autogen, elixir-format
msgid "Invalid or expired link."
msgstr "Invalid or expired link."
#: lib/mv_web/templates/emails/user_confirmation.html.heex
#, elixir-autogen, elixir-format
msgid "Please confirm your email address by clicking the link below."
msgstr "Please confirm your email address by clicking the link below."
#: lib/mv_web/templates/emails/password_reset.html.heex
#, elixir-autogen, elixir-format
msgid "Reset password"
msgstr "Reset password"
#: lib/mv/accounts/user/senders/send_password_reset_email.ex
#, elixir-autogen, elixir-format
msgid "Reset your password"
msgstr "Reset your password"
#: lib/mv_web/controllers/join_confirm_controller.ex
#, elixir-autogen, elixir-format
msgid "Thank you, we have received your request."
msgstr "Thank you, we have received your request."
#: lib/mv_web/controllers/join_confirm_controller.ex
#, elixir-autogen, elixir-format
msgid "This link has expired. Please submit the form again."
msgstr "This link has expired. Please submit the form again."
#: lib/mv_web/templates/emails/join_confirmation.html.heex
#, elixir-autogen, elixir-format
msgid "We have received your membership request. To complete it, please click the link below."
msgstr "We have received your membership request. To complete it, please click the link below."
#: lib/mv_web/templates/emails/password_reset.html.heex
#, elixir-autogen, elixir-format
msgid "You requested a password reset. Click the link below to set a new password."
msgstr "You requested a password reset. Click the link below to set a new password."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Incomplete"

View file

@ -0,0 +1,54 @@
defmodule Mv.Repo.Migrations.AddJoinRequests do
@moduledoc """
Creates join_requests table for the public join flow (onboarding, double opt-in).
Stores join form submissions with status pending_confirmation submitted (after email confirm).
Token stored as hash only; 24h retention for unconfirmed records (cleanup via scheduled job).
"""
use Ecto.Migration
def up do
# uuid_generate_v7() is provided by initialize_extensions migration (custom function)
create table(:join_requests, primary_key: false) do
add :id, :uuid, null: false, default: fragment("uuid_generate_v7()"), primary_key: true
add :status, :text, null: false, default: "pending_confirmation"
add :email, :text, null: false
add :first_name, :text
add :last_name, :text
add :form_data, :map
add :schema_version, :integer
add :confirmation_token_hash, :text
add :confirmation_token_expires_at, :utc_datetime_usec
add :confirmation_sent_at, :utc_datetime_usec
add :submitted_at, :utc_datetime_usec
add :approved_at, :utc_datetime_usec
add :rejected_at, :utc_datetime_usec
add :reviewed_by_user_id, :uuid
add :source, :text
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_confirmation_token_hash_unique",
where: "confirmation_token_hash IS NOT NULL"
)
create index(:join_requests, [:email])
create index(:join_requests, [:status])
end
def down do
drop table(:join_requests)
end
end

View file

@ -0,0 +1,35 @@
defmodule Mv.Membership.JoinRequestSubmitEmailTest do
@moduledoc """
Unit tests for join request confirmation email on submit (Subtask 2).
Asserts that submit_join_request triggers sending exactly one confirmation email
(to the request email, with confirm link). Uses Swoosh.Adapters.Test; no integration.
Sender is wired in implementation; test fails until then (TDD).
"""
use Mv.DataCase, async: true
import Swoosh.TestAssertions
alias Mv.Membership
@valid_submit_attrs %{
email: "join#{System.unique_integer([:positive])}@example.com"
}
describe "submit_join_request/2 sends confirmation email" do
test "sends exactly one email to the request email with confirm link" do
token = "email-test-token-#{System.unique_integer([:positive])}"
attrs = Map.put(@valid_submit_attrs, :confirmation_token, token)
email = attrs.email
assert {:ok, _request} = Membership.submit_join_request(attrs, actor: nil)
assert_email_sent(fn email_sent ->
to_addresses = Enum.map(email_sent.to, &elem(&1, 1))
to_string(email) in to_addresses and
(email_sent.html_body =~ "/confirm_join/" or email_sent.text_body =~ "/confirm_join/")
end)
end
end
end

View file

@ -0,0 +1,130 @@
defmodule Mv.Membership.JoinRequestTest do
@moduledoc """
Tests for JoinRequest resource (public join flow, Subtask 1).
Covers: submit and confirm actions with actor: nil, validations, idempotent confirm,
and policy that non-public actions (e.g. read) are Forbidden with actor: nil.
No UI or email; resource and persistence only.
Requires: Mv.Membership.JoinRequest resource and domain functions
submit_join_request/2, confirm_join_request/2. Policy test requires the resource
to be loaded; unskip when JoinRequest exists.
"""
use Mv.DataCase, async: true
alias Mv.Membership
# Valid minimal attributes for submit (email required; confirmation_token optional for tests)
@valid_submit_attrs %{
email: "join#{System.unique_integer([:positive])}@example.com"
}
describe "submit_join_request/2 (create with actor: nil)" do
test "creates JoinRequest in pending_confirmation with valid attributes and actor nil" do
attrs =
Map.put(
@valid_submit_attrs,
:confirmation_token,
"test-token-#{System.unique_integer([:positive])}"
)
assert {:ok, request} =
Membership.submit_join_request(attrs, actor: nil)
assert request.status == :pending_confirmation
assert request.email == attrs.email
assert request.confirmation_token_hash != nil
assert request.confirmation_token_expires_at != nil
end
test "persists first_name, last_name and form_data when provided" do
attrs =
@valid_submit_attrs
|> Map.put(:confirmation_token, "token-#{System.unique_integer([:positive])}")
|> Map.put(:first_name, "Jane")
|> Map.put(:last_name, "Doe")
|> Map.put(:form_data, %{"city" => "Berlin", "notes" => "Hello"})
|> Map.put(:schema_version, 1)
assert {:ok, request} =
Membership.submit_join_request(attrs, actor: nil)
assert request.first_name == "Jane"
assert request.last_name == "Doe"
assert request.form_data == %{"city" => "Berlin", "notes" => "Hello"}
assert request.schema_version == 1
end
test "returns validation error when email is missing" do
attrs = %{first_name: "Test"} |> Map.delete(:email)
assert {:error, %Ash.Error.Invalid{errors: errors}} =
Membership.submit_join_request(attrs, actor: nil)
assert error_message(errors, :email) =~ "must be present"
end
end
describe "confirm_join_request/2 (update with actor: nil)" do
test "updates JoinRequest to submitted when given valid token and actor nil" do
token = "confirm-token-#{System.unique_integer([:positive])}"
attrs = Map.put(@valid_submit_attrs, :confirmation_token, token)
{:ok, request_before} = Membership.submit_join_request(attrs, actor: nil)
assert request_before.status == :pending_confirmation
assert {:ok, request_after} =
Membership.confirm_join_request(token, actor: nil)
assert request_after.id == request_before.id
assert request_after.status == :submitted
assert request_after.submitted_at != nil
end
test "confirm is idempotent when called twice with same token" do
token = "idempotent-token-#{System.unique_integer([:positive])}"
attrs = Map.put(@valid_submit_attrs, :confirmation_token, token)
{:ok, _} = Membership.submit_join_request(attrs, actor: nil)
{:ok, first} = Membership.confirm_join_request(token, actor: nil)
submitted_at = first.submitted_at
assert {:ok, second} = Membership.confirm_join_request(token, actor: nil)
assert second.status == :submitted
assert second.submitted_at == submitted_at
end
test "returns error when token is unknown or invalid" do
assert {:error, _} = Membership.confirm_join_request("nonexistent-token", actor: nil)
end
test "returns error when token is expired" do
token = "expired-token-#{System.unique_integer([:positive])}"
attrs = Map.put(@valid_submit_attrs, :confirmation_token, token)
{:ok, request} = Membership.submit_join_request(attrs, actor: nil)
past = DateTime.add(DateTime.utc_now(), -1, :hour)
id_binary = Ecto.UUID.dump!(request.id)
from(j in "join_requests", where: fragment("id = ?", ^id_binary))
|> Repo.update_all(set: [confirmation_token_expires_at: past])
assert {:error, :token_expired} =
Membership.confirm_join_request(token, actor: nil)
end
end
describe "policies (actor: nil)" do
test "read with actor nil returns Forbidden" do
assert {:error, %Ash.Error.Forbidden{}} =
Ash.read(Mv.Membership.JoinRequest, actor: nil, domain: Mv.Membership)
end
end
defp error_message(errors, field) do
errors
|> Enum.filter(fn err -> Map.get(err, :field) == field end)
|> Enum.map(&Map.get(&1, :message, ""))
|> List.first()
end
end

View file

@ -0,0 +1,92 @@
defmodule MvWeb.JoinConfirmControllerTest do
@moduledoc """
Unit tests for JoinConfirmController (Subtask 2).
Stubs the join-confirm callback via Application config so no DB or domain is used.
Uses unauthenticated conn; route is public (/confirm*).
"""
use MvWeb.ConnCase, async: false
# Stub modules for configurable callback (unit test: no real Membership calls)
defmodule JoinConfirmValidStub do
def confirm_join_request(_token, _opts), do: {:ok, %{}}
end
defmodule JoinConfirmExpiredStub do
def confirm_join_request(_token, _opts), do: {:error, :token_expired}
end
defmodule JoinConfirmInvalidStub do
def confirm_join_request(_token, _opts) do
{:error, Ash.Error.Query.NotFound.exception(resource: Mv.Membership.JoinRequest)}
end
end
setup %{conn: conn} do
# Restore callback after each test so env does not leak
on_exit(fn ->
Application.delete_env(:mv, :join_confirm_callback)
end)
# Build unauthenticated conn for public confirm route
unauth_conn =
build_conn()
|> init_test_session(%{})
|> fetch_flash()
|> Plug.Conn.put_private(:ecto_sandbox, conn.private[:ecto_sandbox])
{:ok, conn: unauth_conn}
end
describe "GET /confirm_join/:token" do
@tag role: :unauthenticated
test "valid token returns 200 and success message", %{conn: conn} do
Application.put_env(:mv, :join_confirm_callback, JoinConfirmValidStub)
conn = get(conn, "/confirm_join/any-valid-token")
assert response(conn, 200) =~ "received your request"
end
@tag role: :unauthenticated
test "second request with same token still returns 200 (idempotent)", %{conn: conn} do
Application.put_env(:mv, :join_confirm_callback, JoinConfirmValidStub)
first = get(conn, "/confirm_join/same-token")
second = get(conn, "/confirm_join/same-token")
assert response(first, 200) =~ "received your request"
assert response(second, 200) =~ "received your request"
end
@tag role: :unauthenticated
test "expired token returns 200 with expired message", %{conn: conn} do
Application.put_env(:mv, :join_confirm_callback, JoinConfirmExpiredStub)
conn = get(conn, "/confirm_join/expired-token")
assert response(conn, 200) =~ "expired"
assert response(conn, 200) =~ "submit"
end
@tag role: :unauthenticated
test "unknown or invalid token returns 404 with error message", %{conn: conn} do
Application.put_env(:mv, :join_confirm_callback, JoinConfirmInvalidStub)
conn = get(conn, "/confirm_join/nonexistent-token")
assert response(conn, 404) =~ "Invalid"
end
@tag role: :unauthenticated
test "route is public (unauthenticated request returns 200, not redirect to sign-in)", %{
conn: conn
} do
Application.put_env(:mv, :join_confirm_callback, JoinConfirmValidStub)
conn = get(conn, "/confirm_join/public-test-token")
assert conn.status == 200
end
end
end