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
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #463
This commit is contained in:
commit
dbd7afbaf4
34 changed files with 1529 additions and 55 deletions
|
|
@ -85,6 +85,8 @@ lib/
|
||||||
├── membership/ # Membership domain
|
├── membership/ # Membership domain
|
||||||
│ ├── membership.ex # Domain definition
|
│ ├── membership.ex # Domain definition
|
||||||
│ ├── member.ex # Member resource
|
│ ├── 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.ex # Custom field (definition) resource
|
||||||
│ ├── custom_field_value.ex # Custom field value resource
|
│ ├── custom_field_value.ex # Custom field value resource
|
||||||
│ ├── setting.ex # Global settings (singleton resource)
|
│ ├── setting.ex # Global settings (singleton resource)
|
||||||
|
|
@ -1253,32 +1255,32 @@ mix deps.update phoenix
|
||||||
mix hex.outdated
|
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
|
```elixir
|
||||||
defmodule Mv.Mailer do
|
use Phoenix.Swoosh, view: MvWeb.EmailsView, layout: {MvWeb.EmailLayoutView, "layout.html"}
|
||||||
use Swoosh.Mailer, otp_app: :mv
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
**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()
|
new()
|
||||||
|> to({user.name, user.email})
|
|> from(Mailer.mail_from())
|
||||||
|> from({"Mila", "noreply@mila.example.com"})
|
|> to(email_address)
|
||||||
|> subject("Welcome to Mila!")
|
|> subject(gettext("Subject"))
|
||||||
|> render_body("welcome.html", %{user: user})
|
|> put_view(MvWeb.EmailsView)
|
||||||
|> Mv.Mailer.deliver()
|
|> put_layout({MvWeb.EmailLayoutView, "layout.html"})
|
||||||
end
|
|> render_body("template_name.html", %{assigns})
|
||||||
end
|
|> Mailer.deliver!()
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.12 Internationalization: Gettext
|
### 3.12 Internationalization: Gettext
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,10 @@ config :mv, MvWeb.Endpoint,
|
||||||
# at the `config/runtime.exs`.
|
# at the `config/runtime.exs`.
|
||||||
config :mv, Mv.Mailer, adapter: Swoosh.Adapters.Local
|
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)
|
# Configure esbuild (the version is required)
|
||||||
config :esbuild,
|
config :esbuild,
|
||||||
version: "0.17.11",
|
version: "0.17.11",
|
||||||
|
|
|
||||||
|
|
@ -217,9 +217,13 @@ if config_env() == :prod do
|
||||||
#
|
#
|
||||||
# Check `Plug.SSL` for all available options in `force_ssl`.
|
# Check `Plug.SSL` for all available options in `force_ssl`.
|
||||||
|
|
||||||
# ## Configuring the mailer
|
# Transactional emails use the sender from config :mv, :mail_from (overridable via ENV).
|
||||||
#
|
config :mv,
|
||||||
# In production you need to configure the mailer to use a different adapter.
|
: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
|
# 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:
|
# are not using SMTP. Here is an example of the configuration:
|
||||||
#
|
#
|
||||||
|
|
|
||||||
|
|
@ -792,6 +792,23 @@ defmodule MvWeb.Components.SearchBarTest do
|
||||||
end
|
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
|
### Test Data Management
|
||||||
|
|
||||||
**Seed Data:**
|
**Seed Data:**
|
||||||
|
|
|
||||||
26
docs/email-layout-mockup.md
Normal file
26
docs/email-layout-mockup.md
Normal 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).
|
||||||
230
docs/onboarding-join-concept.md
Normal file
230
docs/onboarding-join-concept.md
Normal 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. User–Member linking, admin-only link) untouched until a defined "promotion" flow (e.g. after approval) creates User/Member.
|
||||||
|
- **Elixir/Phoenix/Ash standard:** Data is persisted in the database from the start (one Ash resource, status-driven flow). No ETS or stateless token for pre-confirmation storage; confirm flow only updates the existing record.
|
||||||
|
|
||||||
|
### 2.2 User Flow (Prio 1)
|
||||||
|
|
||||||
|
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 plug’s **`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_user’s 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 plug’s 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 plug’s `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 plug’s public path list** so unauthenticated access is allowed. LiveView (or controller + form). Form fields from allowlist (subtask 3); copy per §2.5. **Honeypot** and **rate limiting** (e.g. Hammer.Plug) on join/submit. After submit: show "We have saved your details … click the link …". Expired-link page: clear message + "submit form again". Public-path tests updated to include `/join`.
|
||||||
|
- **Boundary:** No approval UI, no User/Member creation – only public page, form, anti-abuse, and wiring to submit/confirm flow (subtask 2).
|
||||||
|
- **Done:** Unauthenticated GET `/join` → 200; submit creates JoinRequest (pending_confirmation) and sends email; confirm updates to submitted; honeypot and rate limit tested; public-path tests updated.
|
||||||
|
|
||||||
|
### Order and dependencies
|
||||||
|
|
||||||
|
- **1 → 2:** Submit/confirm flow uses JoinRequest resource.
|
||||||
|
- **3 before or in parallel with 4:** Form reads allowlist from settings; for MVP, subtask 4 can use a default allowlist and 3 can follow shortly after.
|
||||||
|
- **Recommended order:** **1** → **2** → **3** → **4** (or 3 in parallel with 2 if two people work on it).
|
||||||
|
|
||||||
|
### Step 2 – Approval (1 subtask, later)
|
||||||
|
|
||||||
|
#### 5. Approval UI (Vorstand)
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
|
@ -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`
|
- `/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
|
## Test Coverage
|
||||||
|
|
||||||
**File:** `test/mv_web/plugs/check_page_permission_test.exs`
|
**File:** `test/mv_web/plugs/check_page_permission_test.exs`
|
||||||
|
|
|
||||||
147
lib/membership/join_request.ex
Normal file
147
lib/membership/join_request.ex
Normal 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
|
||||||
25
lib/membership/join_request/changes/confirm_request.ex
Normal file
25
lib/membership/join_request/changes/confirm_request.ex
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -9,6 +9,7 @@ defmodule Mv.Membership do
|
||||||
- `Setting` - Global application settings (singleton)
|
- `Setting` - Global application settings (singleton)
|
||||||
- `Group` - Groups that members can belong to
|
- `Group` - Groups that members can belong to
|
||||||
- `MemberGroup` - Join table for many-to-many relationship between Members and Groups
|
- `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
|
## Public API
|
||||||
The domain exposes these main actions:
|
The domain exposes these main actions:
|
||||||
|
|
@ -27,6 +28,10 @@ defmodule Mv.Membership do
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
import Ash.Expr
|
import Ash.Expr
|
||||||
|
alias Ash.Error.Query.NotFound, as: NotFoundError
|
||||||
|
alias Mv.Membership.JoinRequest
|
||||||
|
alias MvWeb.Emails.JoinConfirmationEmail
|
||||||
|
require Logger
|
||||||
|
|
||||||
admin do
|
admin do
|
||||||
show? true
|
show? true
|
||||||
|
|
@ -80,6 +85,10 @@ defmodule Mv.Membership do
|
||||||
define :list_member_groups, action: :read
|
define :list_member_groups, action: :read
|
||||||
define :destroy_member_group, action: :destroy
|
define :destroy_member_group, action: :destroy
|
||||||
end
|
end
|
||||||
|
|
||||||
|
resource Mv.Membership.JoinRequest do
|
||||||
|
# submit_join_request/2 implemented as custom function below (create + send email)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Singleton pattern: Get the single settings record
|
# Singleton pattern: Get the single settings record
|
||||||
|
|
@ -342,4 +351,110 @@ defmodule Mv.Membership do
|
||||||
|> Keyword.put_new(:domain, __MODULE__)
|
|> Keyword.put_new(:domain, __MODULE__)
|
||||||
|> then(&Ash.read_one(query, &1))
|
|> then(&Ash.read_one(query, &1))
|
||||||
end
|
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
|
end
|
||||||
|
|
|
||||||
75
lib/mix/tasks/join_requests.cleanup_expired.ex
Normal file
75
lib/mix/tasks/join_requests.cleanup_expired.ex
Normal 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
|
||||||
|
|
@ -1,12 +1,20 @@
|
||||||
defmodule Mv.Accounts.User.Senders.SendNewUserConfirmationEmail do
|
defmodule Mv.Accounts.User.Senders.SendNewUserConfirmationEmail do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Sends an email for a new user to confirm their email address.
|
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 AshAuthentication.Sender
|
||||||
use MvWeb, :verified_routes
|
|
||||||
|
|
||||||
|
use Phoenix.Swoosh,
|
||||||
|
view: MvWeb.EmailsView,
|
||||||
|
layout: {MvWeb.EmailLayoutView, "layout.html"}
|
||||||
|
|
||||||
|
use MvWeb, :verified_routes
|
||||||
import Swoosh.Email
|
import Swoosh.Email
|
||||||
|
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
|
||||||
|
|
||||||
alias Mv.Mailer
|
alias Mv.Mailer
|
||||||
|
|
||||||
|
|
@ -26,21 +34,22 @@ defmodule Mv.Accounts.User.Senders.SendNewUserConfirmationEmail do
|
||||||
"""
|
"""
|
||||||
@impl true
|
@impl true
|
||||||
def send(user, token, _) do
|
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()
|
new()
|
||||||
# Replace with email from env
|
|> from(Mailer.mail_from())
|
||||||
|> from({"noreply", "noreply@example.com"})
|
|
||||||
|> to(to_string(user.email))
|
|> to(to_string(user.email))
|
||||||
|> subject("Confirm your email address")
|
|> subject(subject)
|
||||||
|> html_body(body(token: token))
|
|> put_view(MvWeb.EmailsView)
|
||||||
|
|> render_body("user_confirmation.html", assigns)
|
||||||
|> Mailer.deliver!()
|
|> Mailer.deliver!()
|
||||||
end
|
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,20 @@
|
||||||
defmodule Mv.Accounts.User.Senders.SendPasswordResetEmail do
|
defmodule Mv.Accounts.User.Senders.SendPasswordResetEmail do
|
||||||
@moduledoc """
|
@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 AshAuthentication.Sender
|
||||||
use MvWeb, :verified_routes
|
|
||||||
|
|
||||||
|
use Phoenix.Swoosh,
|
||||||
|
view: MvWeb.EmailsView,
|
||||||
|
layout: {MvWeb.EmailLayoutView, "layout.html"}
|
||||||
|
|
||||||
|
use MvWeb, :verified_routes
|
||||||
import Swoosh.Email
|
import Swoosh.Email
|
||||||
|
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
|
||||||
|
|
||||||
alias Mv.Mailer
|
alias Mv.Mailer
|
||||||
|
|
||||||
|
|
@ -26,21 +34,22 @@ defmodule Mv.Accounts.User.Senders.SendPasswordResetEmail do
|
||||||
"""
|
"""
|
||||||
@impl true
|
@impl true
|
||||||
def send(user, token, _) do
|
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()
|
new()
|
||||||
# Replace with email from env
|
|> from(Mailer.mail_from())
|
||||||
|> from({"noreply", "noreply@example.com"})
|
|
||||||
|> to(to_string(user.email))
|
|> to(to_string(user.email))
|
||||||
|> subject("Reset your password")
|
|> subject(subject)
|
||||||
|> html_body(body(token: token))
|
|> put_view(MvWeb.EmailsView)
|
||||||
|
|> render_body("password_reset.html", assigns)
|
||||||
|> Mailer.deliver!()
|
|> Mailer.deliver!()
|
||||||
end
|
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
|
end
|
||||||
|
|
|
||||||
17
lib/mv/authorization/checks/actor_is_nil.ex
Normal file
17
lib/mv/authorization/checks/actor_is_nil.ex
Normal 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
|
||||||
|
|
@ -1,3 +1,19 @@
|
||||||
defmodule Mv.Mailer do
|
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
|
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
|
end
|
||||||
|
|
|
||||||
45
lib/mv_web/controllers/join_confirm_controller.ex
Normal file
45
lib/mv_web/controllers/join_confirm_controller.ex
Normal 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
|
||||||
15
lib/mv_web/emails/email_layout_view.ex
Normal file
15
lib/mv_web/emails/email_layout_view.ex
Normal 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
|
||||||
13
lib/mv_web/emails/emails_view.ex
Normal file
13
lib/mv_web/emails/emails_view.ex
Normal 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
|
||||||
43
lib/mv_web/emails/join_confirmation_email.ex
Normal file
43
lib/mv_web/emails/join_confirmation_email.ex
Normal 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
|
||||||
|
|
@ -126,6 +126,9 @@ defmodule MvWeb.Router do
|
||||||
overrides: [MvWeb.AuthOverrides, AshAuthentication.Phoenix.Overrides.DaisyUI],
|
overrides: [MvWeb.AuthOverrides, AshAuthentication.Phoenix.Overrides.DaisyUI],
|
||||||
gettext_backend: {MvWeb.Gettext, "auth"}
|
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.
|
# Remove this if you do not use the magic link strategy.
|
||||||
# magic_sign_in_route(Mv.Accounts.User, :magic_link,
|
# magic_sign_in_route(Mv.Accounts.User, :magic_link,
|
||||||
# auth_routes_prefix: "/auth",
|
# auth_routes_prefix: "/auth",
|
||||||
|
|
|
||||||
18
lib/mv_web/templates/emails/join_confirmation.html.heex
Normal file
18
lib/mv_web/templates/emails/join_confirmation.html.heex
Normal 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>
|
||||||
35
lib/mv_web/templates/emails/layouts/layout.html.heex
Normal file
35
lib/mv_web/templates/emails/layouts/layout.html.heex
Normal 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>
|
||||||
18
lib/mv_web/templates/emails/password_reset.html.heex
Normal file
18
lib/mv_web/templates/emails/password_reset.html.heex
Normal 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>
|
||||||
16
lib/mv_web/templates/emails/user_confirmation.html.heex
Normal file
16
lib/mv_web/templates/emails/user_confirmation.html.heex
Normal 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>
|
||||||
1
mix.exs
1
mix.exs
|
|
@ -65,6 +65,7 @@ defmodule Mv.MixProject do
|
||||||
app: false,
|
app: false,
|
||||||
compile: false,
|
compile: false,
|
||||||
depth: 1},
|
depth: 1},
|
||||||
|
{:phoenix_swoosh, "~> 1.0"},
|
||||||
{:swoosh, "~> 1.16"},
|
{:swoosh, "~> 1.16"},
|
||||||
{:req, "~> 0.5"},
|
{:req, "~> 0.5"},
|
||||||
{:telemetry_metrics, "~> 1.0"},
|
{:telemetry_metrics, "~> 1.0"},
|
||||||
|
|
|
||||||
3
mix.lock
3
mix.lock
|
|
@ -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_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_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_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_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"},
|
"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"},
|
"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": {: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"},
|
"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"},
|
"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"},
|
"ymlr": {:hex, :ymlr, "5.1.4", "b924d61e1fc1ec371cde6ab3ccd9311110b1e052fc5c2460fb322e8380e7712a", [:mix], [], "hexpm", "75f16cf0709fbd911b30311a0359a7aa4b5476346c01882addefd5f2b1cfaa51"},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3240,6 +3240,81 @@ msgstr[1] "%{count} Filter aktiv"
|
||||||
msgid "without %{name}"
|
msgid "without %{name}"
|
||||||
msgstr "ohne %{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
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Incomplete"
|
msgid "Incomplete"
|
||||||
|
|
|
||||||
|
|
@ -3240,6 +3240,81 @@ msgstr[1] ""
|
||||||
msgid "without %{name}"
|
msgid "without %{name}"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Incomplete"
|
msgid "Incomplete"
|
||||||
|
|
|
||||||
|
|
@ -3240,6 +3240,81 @@ msgstr[1] "%{count} filters active"
|
||||||
msgid "without %{name}"
|
msgid "without %{name}"
|
||||||
msgstr "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
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Incomplete"
|
msgid "Incomplete"
|
||||||
|
|
|
||||||
54
priv/repo/migrations/20260309141437_add_join_requests.exs
Normal file
54
priv/repo/migrations/20260309141437_add_join_requests.exs
Normal 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
|
||||||
35
test/membership/join_request_submit_email_test.exs
Normal file
35
test/membership/join_request_submit_email_test.exs
Normal 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
|
||||||
130
test/membership/join_request_test.exs
Normal file
130
test/membership/join_request_test.exs
Normal 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
|
||||||
92
test/mv_web/controllers/join_confirm_controller_test.exs
Normal file
92
test/mv_web/controllers/join_confirm_controller_test.exs
Normal 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue