Finalize join request feature #472

Merged
simon merged 18 commits from feature/308-web-form into main 2026-03-13 20:51:11 +01:00
99 changed files with 3160 additions and 1244 deletions

View file

@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [1.1.0] - 2026-03-13
### Added
- **Browser timezone for datetime display** Date/time values (e.g. join request submitted at, approved at, rejected at) are shown in the users local timezone.
- **Registration toggle** New global setting to disable direct registration (`/register`). When disabled, visitors are redirected to sign-in and the register link is hidden; join form remains available.
- **Configurable SMTP in global settings** SMTP host, port, user, password, and TLS options configurable via Admin → Global Settings. Test-email action to verify delivery. Join confirmation and other transactional emails use this configuration.
- **Theme and language selector on unauthenticated pages** Sign-in and join pages now offer theme (light/dark) and locale (e.g. German/English) controls in the header.
- **Duplicate-email handling for join form** If an applicants email is already a member or already has a pending join request, the system sends a clarifying email (already-member or already-pending) and shows the same success message (anti-enumeration).
- **Reviewed-by display for join requests** Approval UI shows who reviewed a request via a dedicated display field, without loading the User record.
- **Improved field order and seeds for join request approval** Approval screen field order improved; seed data updated for join-form and approval flows.
- **Tests for SMTP mailer configuration** Tests for SMTP config and for join confirmation email delivery failure (domain and LiveView).
### Changed
- **SMTP settings layout** SMTP options reordered and grouped in global settings for clearer configuration.
- **Join confirmation mail** Uses configurable SMTP from settings; on delivery failure the join form shows an error and no success message.
- **i18n** Gettext catalogs updated for new and changed strings.
### Fixed
- **Login page translation** Corrected translation/locale handling on the sign-in page.
---
## [1.0.0] and earlier
### Added ### Added
- **Roles and Permissions System (RBAC)** - Complete implementation (#345, 2026-01-08) - **Roles and Permissions System (RBAC)** - Complete implementation (#345, 2026-01-08)
- Four hardcoded permission sets: `own_data`, `read_only`, `normal_user`, `admin` - Four hardcoded permission sets: `own_data`, `read_only`, `normal_user`, `admin`

View file

@ -90,6 +90,8 @@ lib/
│ ├── 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; incl. join form config) │ ├── setting.ex # Global settings (singleton resource; incl. join form config)
│ ├── settings_cache.ex # Process cache for get_settings (TTL; invalidate on update; not started in test)
│ ├── join_notifier.ex # Behaviour for join emails (confirmation, already member, already pending)
│ ├── setting/ # Setting changes (NormalizeJoinFormSettings, etc.) │ ├── setting/ # Setting changes (NormalizeJoinFormSettings, etc.)
│ ├── group.ex # Group resource │ ├── group.ex # Group resource
│ ├── member_group.ex # MemberGroup join table resource │ ├── member_group.ex # MemberGroup join table resource
@ -1275,6 +1277,8 @@ mix hex.outdated
- SMTP can be configured via **ENV variables** (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_PASSWORD_FILE`, `SMTP_SSL`) or via **Admin Settings** (database: `smtp_host`, `smtp_port`, `smtp_username`, `smtp_password`, `smtp_ssl`). ENV takes priority (same pattern as OIDC/Vereinfacht). - SMTP can be configured via **ENV variables** (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_PASSWORD_FILE`, `SMTP_SSL`) or via **Admin Settings** (database: `smtp_host`, `smtp_port`, `smtp_username`, `smtp_password`, `smtp_ssl`). ENV takes priority (same pattern as OIDC/Vereinfacht).
- **Sensitive settings in DB:** `smtp_password` and `oidc_client_secret` are excluded from the default read of the Setting resource; they are loaded only via explicit select when needed (e.g. `Mv.Config.smtp_password/0`, `Mv.Config.oidc_client_secret/0`). This avoids exposing secrets through `get_settings()`. - **Sensitive settings in DB:** `smtp_password` and `oidc_client_secret` are excluded from the default read of the Setting resource; they are loaded only via explicit select when needed (e.g. `Mv.Config.smtp_password/0`, `Mv.Config.oidc_client_secret/0`). This avoids exposing secrets through `get_settings()`.
- **Settings cache:** `Mv.Membership.get_settings/0` uses `Mv.Membership.SettingsCache` when the cache process is running (not in test). Cache has a short TTL and is invalidated on every settings update. This avoids repeated DB reads on hot paths (e.g. `RegistrationEnabled` validation, `Layouts.public_page`). In test, the cache is not started so all callers use `get_settings_uncached/0` in the test process (Ecto Sandbox).
- **Join emails (domain → web):** The domain calls `Mv.Membership.JoinNotifier` (config `:join_notifier`, default `MvWeb.JoinNotifierImpl`) for sending join confirmation, already-member, and already-pending emails. This keeps the domain independent of the web layer; tests can override the notifier.
- Sender identity is also configurable via ENV (`MAIL_FROM_NAME`, `MAIL_FROM_EMAIL`) or Settings (`smtp_from_name`, `smtp_from_email`). - Sender identity is also configurable via ENV (`MAIL_FROM_NAME`, `MAIL_FROM_EMAIL`) or Settings (`smtp_from_name`, `smtp_from_email`).
- `SMTP_PASSWORD_FILE`: path to a file containing the password (Docker Secrets / Kubernetes secrets pattern); overridden by `SMTP_PASSWORD` when both are set. - `SMTP_PASSWORD_FILE`: path to a file containing the password (Docker Secrets / Kubernetes secrets pattern); overridden by `SMTP_PASSWORD` when both are set.
- `SMTP_SSL` values: `tls` (default, port 587), `ssl` (port 465), `none` (port 25). - `SMTP_SSL` values: `tls` (default, port 587), `ssl` (port 465), `none` (port 25).
@ -1290,6 +1294,10 @@ mix hex.outdated
- `SendPasswordResetEmail` and `SendNewUserConfirmationEmail` use `Mv.Mailer.deliver/1` (not `deliver!/1`). Errors are logged via `Logger.error` and not re-raised so they never crash the caller process. - `SendPasswordResetEmail` and `SendNewUserConfirmationEmail` use `Mv.Mailer.deliver/1` (not `deliver!/1`). Errors are logged via `Logger.error` and not re-raised so they never crash the caller process.
**Join confirmation email:**
- Join emails are sent via `Mv.Membership.JoinNotifier` (default impl: `MvWeb.JoinNotifierImpl` calling `JoinConfirmationEmail`, etc.). `MvWeb.Emails.JoinConfirmationEmail` uses `Mailer.deliver(email, Mailer.smtp_config())` so it uses the same SMTP configuration as the test mail (Settings or boot ENV). On delivery failure, `Mv.Membership.submit_join_request/2` returns `{:error, :email_delivery_failed}` (and logs via `Logger.error`); the JoinLive shows an error message and no success UI.
**Unified layout (transactional emails):** **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). - All transactional emails (join confirmation, user confirmation, password reset) use the same layout: `MvWeb.EmailLayoutView` (layout) and `MvWeb.EmailsView` (body templates).

View file

@ -76,6 +76,21 @@ For LiveViews that render an edit or new form (e.g. member, group, role, user, c
If the `<.header>` is outside the `<.form>`, the submit button must reference the form via the `form` attribute (e.g. `form="user-form"`). If the `<.header>` is outside the `<.form>`, the submit button must reference the form via the `form` attribute (e.g. `form="user-form"`).
### 2.3 Public / unauthenticated pages (Join, Sign-in, Join Confirm)
Pages that do not require authentication (e.g. `/join`, `/sign-in`, `/confirm_join/:token`) use a unified layout via the **`Layouts.public_page`** component:
- **Component:** `Layouts.public_page` renders:
- **Header:** Logo + "Mitgliederverwaltung" (left) | Club name centered via absolute positioning | Language selector + theme swap (sun/moon, DaisyUI swap with rotate) (right)
- Main content slot, Flash group. No sidebar, no authenticated-layout logic.
- **Content:** DaisyUI **hero** section (`hero`, `hero-content`) for the main message or form, so all public pages share the same visual structure. The hero is constrained in width (`max-w-4xl mx-auto`) and content is left-aligned (`hero-content flex-col items-start text-left`).
- **Locale handling:** The language selector uses `Gettext.get_locale(MvWeb.Gettext)` (backend-specific) to correctly reflect the active locale. `SignInLive` sets both `Gettext.put_locale(MvWeb.Gettext, locale)` and `Gettext.put_locale(locale)` to keep global and backend locales in sync.
- **Translations for AshAuthentication components:** AshAuthentications `_gettext` mechanism translates button labels (e.g. “Sign in” → “Anmelden”, “Register” → “Registrieren”) at runtime via `gettext_fn: {MvWeb.Gettext, "auth"}`. Components that do NOT use `_gettext` (e.g. `HorizontalRule`) receive static German overrides via **`MvWeb.AuthOverridesDE`**, which is prepended to the overrides list in `SignInLive` when the locale is `"de"`.
- **Implementation:**
- **Sign-in** (`SignInLive`): Uses `use Phoenix.LiveView` (not `use MvWeb, :live_view`) so AshAuthentications sign_in_route live_session on_mount chain is not mixed with LiveHelpers hooks. Renders `<Layouts.public_page flash={@flash}>` with the SignIn component inside a hero. Displays a locale-aware `<h1>` title (“Anmelden” / “Registrieren”) above the AshAuthentication component (the librarys Banner is hidden via `show_banner: false`).
- **Join** (`JoinLive`): Uses `use MvWeb, :live_view` and wraps content in `<Layouts.public_page flash={@flash}>` with a hero for the form.
- **Join Confirm** (controller): Uses `JoinConfirmHTML` with a template that wraps content in `<Layouts.public_page flash={@flash}>` and a hero block for the result, so the confirm page shares the same header and chrome as Join and Sign-in.
## 3) Typography (system) ## 3) Typography (system)
Use these standard roles: Use these standard roles:
@ -83,16 +98,18 @@ Use these standard roles:
| Role | Use | Class | | Role | Use | Class |
|---|---|---| |---|---|---|
| Page title (H1) | main page title | `text-xl font-semibold leading-8` | | Page title (H1) | main page title | `text-xl font-semibold leading-8` |
| Subtitle | helper under title | `text-sm text-base-content/70` | | Subtitle | helper under title | `text-sm text-base-content/85` |
| Section title (H2) | section headings | `text-lg font-semibold` | | Section title (H2) | section headings | `text-lg font-semibold` |
| Helper text | under inputs | `text-sm text-base-content/70` | | Helper text | under inputs | `text-sm text-base-content/85` |
| Fine print | small hints | `text-xs text-base-content/60` | | Fine print | small hints | `text-xs text-base-content/80` |
| Empty state | no data | `text-base-content/60 italic` | | Empty state | no data | `text-base-content/80 italic` |
| Destructive text | danger | `text-error` | | Destructive text | danger | `text-error` |
**MUST:** Page titles via `<.header>`. **MUST:** Page titles via `<.header>`.
**MUST:** Section titles via `<.form_section title="…">` (for forms) or a consistent section wrapper (if you introduce a `<.card>` later). **MUST:** Section titles via `<.form_section title="…">` (for forms) or a consistent section wrapper (if you introduce a `<.card>` later).
**Form labels (WCAG 2.2 AA):** DaisyUI `.label` defaults to 60% opacity and fails contrast. We override it in `app.css` to 85% of `base-content` so labels stay slightly deemphasised vs body text but meet the 4.5:1 minimum. Use `class="label"` and `<span class="label-text">` as usual; no extra classes needed.
--- ---
## 4) States: Loading, Empty, Error (mandatory consistency) ## 4) States: Loading, Empty, Error (mandatory consistency)
@ -204,6 +221,11 @@ If these cannot be met, use `secondary`/`outline` instead of `ghost`.
- **MUST:** Required fields are marked consistently (UI indicator + accessible text). - **MUST:** Required fields are marked consistently (UI indicator + accessible text).
- **SHOULD:** If required-ness is configurable via settings, display it consistently in the form. - **SHOULD:** If required-ness is configurable via settings, display it consistently in the form.
### 6.4 Form layout (settings / long forms)
- **SHOULD:** On wide viewports, use a responsive grid so related fields share a row and reduce scrolling (e.g. `grid grid-cols-1 lg:grid-cols-2` or `lg:grid-cols-[2fr_5rem_1fr]` for mixed widths).
- **SHOULD:** Limit the main content width for readability (e.g. Settings page uses `max-w-4xl mx-auto px-4` around the content area below the header).
- **Example:** SMTP settings use three rows on large screens (Host, Port, TLS/SSL | Username, Password | Sender email, Sender name) without subsection labels.
--- ---
## 7) Lists, Search & Filters (mandatory UX consistency) ## 7) Lists, Search & Filters (mandatory UX consistency)

View file

@ -10,6 +10,7 @@ install-dependencies:
mix deps.get mix deps.get
migrate-database: migrate-database:
mix compile
mix ash.setup mix ash.setup
reset-database: reset-database:

View file

@ -154,6 +154,14 @@
background-color: var(--color-base-100); background-color: var(--color-base-100);
} }
/* WCAG 2.2 AA (4.5:1 for normal text): Form labels. DaisyUI .label uses 60% opacity,
which fails contrast. Override to 85% of base-content so labels stay slightly
deemphasised vs body text but meet the minimum ratio. Match .label directly
so the override applies even when data-theme is not yet set (e.g. initial load). */
.label {
color: color-mix(in oklab, var(--color-base-content) 85%, transparent);
}
/* WCAG 2.2 AA (4.5:1 for normal text): Badge text must contrast with badge background. /* WCAG 2.2 AA (4.5:1 for normal text): Badge text must contrast with badge background.
Theme tokens *-content are often too light on * backgrounds in light theme, and Theme tokens *-content are often too light on * backgrounds in light theme, and
badge-soft uses variant as text on a light tint (low contrast). We override badge-soft uses variant as text on a light tint (low contrast). We override

View file

@ -25,6 +25,14 @@ import Sortable from "../vendor/sortable"
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
function getBrowserTimezone() {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || null
} catch (_e) {
return null
}
}
// Hooks for LiveView components // Hooks for LiveView components
let Hooks = {} let Hooks = {}
@ -312,7 +320,10 @@ Hooks.SidebarState = {
let liveSocket = new LiveSocket("/live", Socket, { let liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500, longPollFallbackMs: 2500,
params: {_csrf_token: csrfToken}, params: {
_csrf_token: csrfToken,
timezone: getBrowserTimezone()
},
hooks: Hooks hooks: Hooks
}) })

View file

@ -46,6 +46,9 @@ config :spark,
] ]
] ]
# IANA timezone database for DateTime.shift_zone (browser timezone display)
config :elixir, :time_zone_database, Tz.TimeZoneDatabase
config :mv, config :mv,
ecto_repos: [Mv.Repo], ecto_repos: [Mv.Repo],
generators: [timestamp_type: :utc_datetime], generators: [timestamp_type: :utc_datetime],
@ -104,6 +107,9 @@ config :mv, :mail_from, {"Mila", "noreply@example.com"}
# Join form rate limiting (Hammer). scale_ms: window in ms, limit: max submits per window per IP. # Join form rate limiting (Hammer). scale_ms: window in ms, limit: max submits per window per IP.
config :mv, :join_rate_limit, scale_ms: 60_000, limit: 10 config :mv, :join_rate_limit, scale_ms: 60_000, limit: 10
# Join emails: notifier implementation (domain → web abstraction). Override in test to inject a mock.
config :mv, :join_notifier, MvWeb.JoinNotifierImpl
# Configure esbuild (the version is required) # Configure esbuild (the version is required)
config :esbuild, config :esbuild,
version: "0.17.11", version: "0.17.11",

View file

@ -58,3 +58,7 @@ config :mv, :sql_sandbox, true
# Join form rate limit: low limit so tests can trigger rate limiting (e.g. 2 per minute) # Join form rate limit: low limit so tests can trigger rate limiting (e.g. 2 per minute)
config :mv, :join_rate_limit, scale_ms: 60_000, limit: 2 config :mv, :join_rate_limit, scale_ms: 60_000, limit: 2
# Ash: silence "after_transaction hooks in surrounding transaction" warning when using
# Ecto sandbox (tests run in a transaction; create_member after_transaction is expected).
config :ash, warn_on_transaction_hooks?: false

View file

@ -806,7 +806,7 @@ end
- **Senders migrated:** `SendNewUserConfirmationEmail`, `SendPasswordResetEmail` use layout + `Mv.Mailer.mail_from/0`. - **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. - **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. - **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. - **PR review follow-ups (Join confirmation):** Join confirmation email uses `Mailer.deliver/2` with `Mailer.smtp_config/0` (same config as test mail). On delivery failure the domain returns `{:error, :email_delivery_failed}` (logged via `Logger.error`), and the JoinLive shows an error message (no success UI). 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. - Tests: `join_request_test.exs`, `join_request_submit_email_test.exs`, `join_confirm_controller_test.exs` all pass.
**Subtask 3 Admin: Join form settings (done):** **Subtask 3 Admin: Join form settings (done):**

View file

@ -36,10 +36,10 @@
**Closed Issues:** **Closed Issues:**
- ✅ [#171](https://git.local-it.org/local-it/mitgliederverwaltung/issues/171) - OIDC handling and linking (closed 2025-11-13) - ✅ [#171](https://git.local-it.org/local-it/mitgliederverwaltung/issues/171) - OIDC handling and linking (closed 2025-11-13)
- ✅ [#146](https://git.local-it.org/local-it/mitgliederverwaltung/issues/146) - Translate "or" in the login screen — fixed via `MvWeb.AuthOverridesDE` locale-specific module (2026-03-13)
- ✅ [#144](https://git.local-it.org/local-it/mitgliederverwaltung/issues/144) - Add language switch dropdown to login screen — fixed locale selector bug with `Gettext.get_locale(MvWeb.Gettext)` (2026-03-13)
**Open Issues:** **Open Issues:** (none remaining for Authentication UI)
- [#146](https://git.local-it.org/local-it/mitgliederverwaltung/issues/146) - Translate "or" in the login screen (Low)
- [#144](https://git.local-it.org/local-it/mitgliederverwaltung/issues/144) - Add language switch dropdown to login screen (Low)
**Current State:** **Current State:**
- ✅ **Role-based access control (RBAC)** - Implemented (2026-01-08, PR #346, closes #345) - ✅ **Role-based access control (RBAC)** - Implemented (2026-01-08, PR #346, closes #345)

View file

@ -93,6 +93,7 @@
- **Placement:** Own section **"Onboarding / Join"** in global settings, **above** "Custom fields", **below** "Vereinsdaten" (club data). - **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. - **Join form enabled:** Checkbox (e.g. `join_form_enabled`). When set, the public `/join` page is active and the following config applies.
- **Copyable join link:** When the join form is enabled, a copyable full URL to the `/join` page is shown below the checkbox (above the field list), with a short hint so admins can share it with applicants.
- **Field selection:** From **all existing** member fields (from `Mv.Constants.member_fields()`) and **custom fields**, the admin selects which fields appear on the join form. Stored as a list/set of field identifiers (no separate table); display in settings as a simple list, e.g. **badges with X to remove** (similar to the groups overview). Adding fields: e.g. dropdown or modal to pick from remaining fields. Detailed UX for this subsection is to be specified in a **separate subtask**. - **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. - **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. - **Other:** Which entry paths are enabled, approval workflow (who can approve) to be detailed in Step 2 and later specs.
@ -115,7 +116,7 @@ Implementation spec for Subtask 5.
#### Route and pages #### Route and pages
- **List:** **`/join_requests`** list of join requests. Filter by status (default or primary view: status `submitted`); optional view for "all" or "approved/rejected" for audit. - **List:** **`/join_requests`** list of join requests. Filter by status (default or primary view: status `submitted`); optional view for "all" or "approved/rejected" for audit.
- **Detail:** **`/join_requests/:id`** single join request with all data (typed fields + `form_data`), actions Approve / Reject. - **Detail:** **`/join_requests/:id`** single join request. **Two blocks:** (1) **Applicant data** all form fields (typed + `form_data`) merged and shown in join-form order; (2) **Status and review** submitted_at, status, and when decided: approved_at/rejected_at, reviewed by. Actions Approve / Reject when status is `submitted`.
#### Backend (JoinRequest) #### Backend (JoinRequest)
@ -195,7 +196,7 @@ Implementation spec for Subtask 5.
- **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). - **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. - **Confirmation route:** **`/confirm_join/:token`** so existing `starts_with?(path, "/confirm")` covers it.
- **Public path for `/join`:** **Add `/join` explicitly** to the page-permission plugs `public_path?/1` (e.g. in `CheckPagePermission`) so unauthenticated users can reach the join page. - **Public path for `/join`:** **Add `/join` explicitly** to the page-permission plugs `public_path?/1` (e.g. in `CheckPagePermission`) so unauthenticated users can reach the join page.
- **JoinRequest schema:** Status `pending_confirmation` | `submitted` | `approved` | `rejected`. Typed: **email** (required), **first_name**, **last_name** (optional). **form_data** (jsonb) + **schema_version** for remaining form fields. **confirmation_token_hash**, **confirmation_token_expires_at**; **submitted_at**, **approved_at**, **rejected_at**, **reviewed_by_user_id** for audit. Idempotent confirm (unique constraint on token hash or update only when status is `pending_confirmation`). - **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**, **reviewed_by_display** (denormalized reviewer email for "Geprüft von" without loading User) 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**. - **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). - **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**. - **Settings:** Own section "Onboarding / Join" in global settings; `join_form_enabled` plus field selection; display as list/badges; detailed UX in a **separate subtask**.

View file

@ -0,0 +1,44 @@
# Settings page Authentication section (ASCII mockup)
Structure after renaming "OIDC" to "Authentication" and adding the registration toggle.
Subsections use their own headings (h3) inside the main "Authentication" form_section.
+------------------------------------------------------------------+
| Settings |
| Manage global settings for the association. |
+------------------------------------------------------------------+
+-- Club Settings -------------------------------------------------+
| Association Name: [________________] [Save Name] |
+------------------------------------------------------------------+
+-- Join Form -----------------------------------------------------+
| ... (unchanged) |
+------------------------------------------------------------------+
+-- SMTP / E-Mail -------------------------------------------------+
| ... |
+------------------------------------------------------------------+
+-- Accounting-Software (Vereinfacht) Integration -----------------+
| ... |
+------------------------------------------------------------------+
+-- Authentication ------------------------------------------------+ <-- main section (renamed from "OIDC (Single Sign-On)")
| |
| Direct registration | <-- subsection heading (h3)
| [x] Allow direct registration (/register) |
| If disabled, users cannot sign up via /register; sign-in |
| and the join form remain available. |
| |
| OIDC (Single Sign-On) | <-- subsection heading (h3)
| (Some values are set via environment variables...) |
| Client ID: [________________] |
| Base URL: [________________] |
| Redirect URI: [________________] |
| Client Secret: [________________] (set) |
| Admin group name: [________________] |
| Groups claim: [________________] |
| [ ] Only OIDC sign-in (hide password login) |
| [Save OIDC Settings] |
+------------------------------------------------------------------+

View file

@ -44,6 +44,8 @@ When an ENV variable is set, the corresponding Settings field is read-only in th
**Important:** On most SMTP servers (e.g. Postfix with strict relay policies) the sender email (`smtp_from_email`) must be the same address as `smtp_username` or an alias that is owned by that account. **Important:** On most SMTP servers (e.g. Postfix with strict relay policies) the sender email (`smtp_from_email`) must be the same address as `smtp_username` or an alias that is owned by that account.
**Settings UI:** The form uses three rows on wide viewports: host, port, TLS/SSL | username, password | sender email, sender name. Content width is limited by the global settings wrapper (see `DESIGN_GUIDELINES.md` §6.4).
--- ---
## 5. Password from File ## 5. Password from File
@ -82,13 +84,19 @@ Provided by `Mv.Config.mail_from_name/0` and `Mv.Config.mail_from_email/0`.
--- ---
## 9. AshAuthentication Senders ## 9. Join Confirmation Email
`MvWeb.Emails.JoinConfirmationEmail` uses the same SMTP configuration as the test email: `Mailer.deliver(email, Mailer.smtp_config())`. This ensures Settings-based SMTP is used when not configured via ENV at boot. On delivery failure the domain returns `{:error, :email_delivery_failed}` (and logs via `Logger.error`); the JoinLive shows an error message and no success UI.
---
## 10. AshAuthentication Senders
Both `SendPasswordResetEmail` and `SendNewUserConfirmationEmail` use `Mv.Mailer.deliver/1` (not `deliver!/1`). Delivery failures are logged (`Logger.error`) and not re-raised, so they never crash the caller process. AshAuthentication ignores the return value of `send/3`. Both `SendPasswordResetEmail` and `SendNewUserConfirmationEmail` use `Mv.Mailer.deliver/1` (not `deliver!/1`). Delivery failures are logged (`Logger.error`) and not re-raised, so they never crash the caller process. AshAuthentication ignores the return value of `send/3`.
--- ---
## 10. TLS / SSL in OTP 27 ## 11. TLS / SSL in OTP 27
OTP 26+ enforces `verify_peer` by default, which fails for self-signed or internal SMTP server certificates. OTP 26+ enforces `verify_peer` by default, which fails for self-signed or internal SMTP server certificates.
@ -101,7 +109,7 @@ Both `tls_options` (STARTTLS, port 587) and `sockopts` (direct SSL, port 465) us
--- ---
## 11. Summary Checklist ## 12. Summary Checklist
- [x] ENV: `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_PASSWORD_FILE`, `SMTP_SSL`. - [x] ENV: `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_PASSWORD_FILE`, `SMTP_SSL`.
- [x] ENV: `MAIL_FROM_NAME`, `MAIL_FROM_EMAIL` for sender identity. - [x] ENV: `MAIL_FROM_NAME`, `MAIL_FROM_EMAIL` for sender identity.
@ -112,13 +120,14 @@ Both `tls_options` (STARTTLS, port 587) and `sockopts` (direct SSL, port 465) us
- [x] TLS certificate validation relaxed for OTP 27 (tls_options + sockopts). - [x] TLS certificate validation relaxed for OTP 27 (tls_options + sockopts).
- [x] Prod warning: clear message in Settings when SMTP is not configured. - [x] Prod warning: clear message in Settings when SMTP is not configured.
- [x] Test email: form with recipient field, translatable content, classified success/error messages. - [x] Test email: form with recipient field, translatable content, classified success/error messages.
- [x] Join confirmation email: uses `Mailer.smtp_config/0` (same as test mail); on failure returns `{:error, :email_delivery_failed}`, error shown in JoinLive, logged for admin.
- [x] AshAuthentication senders: graceful error handling (no crash on delivery failure). - [x] AshAuthentication senders: graceful error handling (no crash on delivery failure).
- [x] Gettext for all new UI strings, translated to German. - [x] Gettext for all new UI strings, translated to German.
- [x] Docs and code guidelines updated. - [x] Docs and code guidelines updated.
--- ---
## 12. Follow-up / Future Work ## 13. Follow-up / Future Work
- **SMTP password at-rest encryption:** The `smtp_password` attribute is currently stored in plaintext in the `settings` table. It is excluded from default reads (same pattern as `oidc_client_secret`); both are read only via explicit select when needed. For production systems at-rest encryption (e.g. with [Cloak](https://hexdocs.pm/cloak)) should be considered and tracked as a follow-up issue. - **SMTP password at-rest encryption:** The `smtp_password` attribute is currently stored in plaintext in the `settings` table. It is excluded from default reads (same pattern as `oidc_client_secret`); both are read only via explicit select when needed. For production systems at-rest encryption (e.g. with [Cloak](https://hexdocs.pm/cloak)) should be considered and tracked as a follow-up issue.
- **Error classification:** SMTP error categorization currently uses substring matching on server messages (e.g. "535", "authentication"). A more robust approach would be to pattern-match on `gen_smtp` error tuples first where possible, and fall back to string analysis only when needed. Server wording varies; consider extending patterns as new providers are used. - **Error classification:** SMTP error categorization currently uses substring matching on server messages (e.g. "535", "authentication"). A more robust approach would be to pattern-match on `gen_smtp` error tuples first where possible, and fall back to string analysis only when needed. Server wording varies; consider extending patterns as new providers are used.

View file

@ -405,6 +405,10 @@ defmodule Mv.Accounts.User do
where: [action_is([:register_with_password, :admin_set_password])], where: [action_is([:register_with_password, :admin_set_password])],
message: "must have length of at least 8" message: "must have length of at least 8"
# Block direct registration when disabled in global settings
validate {Mv.Accounts.User.Validations.RegistrationEnabled, []},
where: [action_is(:register_with_password)]
# Email uniqueness check for all actions that change the email attribute # Email uniqueness check for all actions that change the email attribute
# Validates that user email is not already used by another (unlinked) member # Validates that user email is not already used by another (unlinked) member
validate Mv.Accounts.User.Validations.EmailNotUsedByOtherMember validate Mv.Accounts.User.Validations.EmailNotUsedByOtherMember

View file

@ -0,0 +1,31 @@
defmodule Mv.Accounts.User.Validations.RegistrationEnabled do
@moduledoc """
Validation that blocks direct registration (register_with_password) when
registration is disabled in global settings. Used so that even direct API/form
submissions cannot register when the setting is off.
"""
use Ash.Resource.Validation
alias Mv.Membership
@impl true
def init(opts), do: {:ok, opts}
@impl true
def validate(_changeset, _opts, _context) do
case Membership.get_settings() do
{:ok, %{registration_enabled: true}} ->
:ok
_ ->
{:error,
field: :base,
message:
Gettext.dgettext(
MvWeb.Gettext,
"default",
"Registration is disabled. Please use the join form or contact an administrator."
)}
end
end
end

View file

@ -0,0 +1,13 @@
defmodule Mv.Membership.JoinNotifier do
@moduledoc """
Behaviour for sending join-related emails (confirmation, already member, already pending).
The domain calls this module instead of MvWeb.Emails directly, so the domain layer
does not depend on the web layer. The default implementation is set in config
(`config :mv, :join_notifier, MvWeb.JoinNotifierImpl`). Tests can override with a mock.
"""
@callback send_confirmation(email :: String.t(), token :: String.t(), opts :: keyword()) ::
{:ok, term()} | {:error, term()}
@callback send_already_member(email :: String.t()) :: {:ok, term()} | {:error, term()}
@callback send_already_pending(email :: String.t()) :: {:ok, term()} | {:error, term()}
end

View file

@ -77,6 +77,17 @@ defmodule Mv.Membership.JoinRequest do
change Mv.Membership.JoinRequest.Changes.RejectRequest change Mv.Membership.JoinRequest.Changes.RejectRequest
end end
# Internal: resend confirmation (new token) when user submits form again with same email.
# Called from domain with authorize?: false; not exposed to public.
update :regenerate_confirmation_token do
description "Set new confirmation token and expiry (resend flow)"
require_atomic? false
argument :confirmation_token, :string, allow_nil?: false
change Mv.Membership.JoinRequest.Changes.RegenerateConfirmationToken
end
end end
policies do policies do
@ -175,6 +186,11 @@ defmodule Mv.Membership.JoinRequest do
attribute :approved_at, :utc_datetime_usec attribute :approved_at, :utc_datetime_usec
attribute :rejected_at, :utc_datetime_usec attribute :rejected_at, :utc_datetime_usec
attribute :reviewed_by_user_id, :uuid attribute :reviewed_by_user_id, :uuid
attribute :reviewed_by_display, :string do
description "Denormalized reviewer display (e.g. email) for UI without loading User"
end
attribute :source, :string attribute :source, :string
create_timestamp :inserted_at create_timestamp :inserted_at

View file

@ -16,11 +16,13 @@ defmodule Mv.Membership.JoinRequest.Changes.ApproveRequest do
if current_status == :submitted do if current_status == :submitted do
reviewed_by_id = Helpers.actor_id(context.actor) reviewed_by_id = Helpers.actor_id(context.actor)
reviewed_by_display = Helpers.actor_email(context.actor)
changeset changeset
|> Ash.Changeset.force_change_attribute(:status, :approved) |> Ash.Changeset.force_change_attribute(:status, :approved)
|> Ash.Changeset.force_change_attribute(:approved_at, DateTime.utc_now()) |> Ash.Changeset.force_change_attribute(:approved_at, DateTime.utc_now())
|> Ash.Changeset.force_change_attribute(:reviewed_by_user_id, reviewed_by_id) |> Ash.Changeset.force_change_attribute(:reviewed_by_user_id, reviewed_by_id)
|> Ash.Changeset.force_change_attribute(:reviewed_by_display, reviewed_by_display)
else else
Ash.Changeset.add_error(changeset, Ash.Changeset.add_error(changeset,
field: :status, field: :status,

View file

@ -16,4 +16,24 @@ defmodule Mv.Membership.JoinRequest.Changes.Helpers do
end end
def actor_id(_), do: nil def actor_id(_), do: nil
@doc """
Extracts the actor's email for display (e.g. reviewed_by_display).
Supports both atom and string keys for compatibility with different actor representations.
"""
@spec actor_email(term()) :: String.t() | nil
def actor_email(nil), do: nil
def actor_email(actor) when is_map(actor) do
raw = Map.get(actor, :email) || Map.get(actor, "email")
if is_nil(raw), do: nil, else: actor_email_string(raw)
end
def actor_email(_), do: nil
defp actor_email_string(raw) do
s = raw |> to_string() |> String.trim()
if s == "", do: nil, else: s
end
end end

View file

@ -0,0 +1,33 @@
defmodule Mv.Membership.JoinRequest.Changes.RegenerateConfirmationToken do
@moduledoc """
Sets a new confirmation token hash and expiry on an existing join request (resend flow).
Used when the user submits the join form again with the same email while a request
is still pending_confirmation. Internal use only (domain calls with authorize?: false).
"""
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
now = DateTime.utc_now()
expires_at = DateTime.add(now, @confirmation_validity_hours, :hour)
changeset
|> Ash.Changeset.force_change_attribute(
:confirmation_token_hash,
JoinRequest.hash_confirmation_token(token)
)
|> Ash.Changeset.force_change_attribute(:confirmation_token_expires_at, expires_at)
|> Ash.Changeset.force_change_attribute(:confirmation_sent_at, now)
else
changeset
end
end
end

View file

@ -15,11 +15,13 @@ defmodule Mv.Membership.JoinRequest.Changes.RejectRequest do
if current_status == :submitted do if current_status == :submitted do
reviewed_by_id = Helpers.actor_id(context.actor) reviewed_by_id = Helpers.actor_id(context.actor)
reviewed_by_display = Helpers.actor_email(context.actor)
changeset changeset
|> Ash.Changeset.force_change_attribute(:status, :rejected) |> Ash.Changeset.force_change_attribute(:status, :rejected)
|> Ash.Changeset.force_change_attribute(:rejected_at, DateTime.utc_now()) |> Ash.Changeset.force_change_attribute(:rejected_at, DateTime.utc_now())
|> Ash.Changeset.force_change_attribute(:reviewed_by_user_id, reviewed_by_id) |> Ash.Changeset.force_change_attribute(:reviewed_by_user_id, reviewed_by_id)
|> Ash.Changeset.force_change_attribute(:reviewed_by_display, reviewed_by_display)
else else
Ash.Changeset.add_error(changeset, Ash.Changeset.add_error(changeset,
field: :status, field: :status,

View file

@ -29,8 +29,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 Ash.Error.Query.NotFound, as: NotFoundError
alias Mv.Helpers.SystemActor
alias Mv.Membership.JoinRequest alias Mv.Membership.JoinRequest
alias MvWeb.Emails.JoinConfirmationEmail alias Mv.Membership.Member
alias Mv.Membership.SettingsCache
require Logger require Logger
admin do admin do
@ -114,10 +116,16 @@ defmodule Mv.Membership do
""" """
def get_settings do def get_settings do
# Try to get the first (and only) settings record case Process.whereis(SettingsCache) do
nil -> get_settings_uncached()
_pid -> SettingsCache.get()
end
end
@doc false
def get_settings_uncached do
case Ash.read_one(Mv.Membership.Setting, domain: __MODULE__) do case Ash.read_one(Mv.Membership.Setting, domain: __MODULE__) do
{:ok, nil} -> {:ok, nil} ->
# No settings exist - create as fallback (should normally be created via seed script)
default_club_name = System.get_env("ASSOCIATION_NAME") || "Club Name" default_club_name = System.get_env("ASSOCIATION_NAME") || "Club Name"
Mv.Membership.Setting Mv.Membership.Setting
@ -158,9 +166,16 @@ defmodule Mv.Membership do
""" """
def update_settings(settings, attrs) do def update_settings(settings, attrs) do
settings case settings
|> Ash.Changeset.for_update(:update, attrs) |> Ash.Changeset.for_update(:update, attrs)
|> Ash.update(domain: __MODULE__) |> Ash.update(domain: __MODULE__) do
{:ok, _updated} = result ->
SettingsCache.invalidate()
result
error ->
error
end
end end
@doc """ @doc """
@ -224,11 +239,18 @@ defmodule Mv.Membership do
""" """
def update_member_field_visibility(settings, visibility_config) do def update_member_field_visibility(settings, visibility_config) do
settings case settings
|> Ash.Changeset.for_update(:update_member_field_visibility, %{ |> Ash.Changeset.for_update(:update_member_field_visibility, %{
member_field_visibility: visibility_config member_field_visibility: visibility_config
}) })
|> Ash.update(domain: __MODULE__) |> Ash.update(domain: __MODULE__) do
{:ok, _} = result ->
SettingsCache.invalidate()
result
error ->
error
end
end end
@doc """ @doc """
@ -261,12 +283,19 @@ defmodule Mv.Membership do
field: field, field: field,
show_in_overview: show_in_overview show_in_overview: show_in_overview
) do ) do
settings case settings
|> Ash.Changeset.new() |> Ash.Changeset.new()
|> Ash.Changeset.set_argument(:field, field) |> Ash.Changeset.set_argument(:field, field)
|> Ash.Changeset.set_argument(:show_in_overview, show_in_overview) |> Ash.Changeset.set_argument(:show_in_overview, show_in_overview)
|> Ash.Changeset.for_update(:update_single_member_field_visibility, %{}) |> Ash.Changeset.for_update(:update_single_member_field_visibility, %{})
|> Ash.update(domain: __MODULE__) |> Ash.update(domain: __MODULE__) do
{:ok, _} = result ->
SettingsCache.invalidate()
result
error ->
error
end
end end
@doc """ @doc """
@ -300,13 +329,20 @@ defmodule Mv.Membership do
show_in_overview: show_in_overview, show_in_overview: show_in_overview,
required: required required: required
) do ) do
settings case settings
|> Ash.Changeset.new() |> Ash.Changeset.new()
|> Ash.Changeset.set_argument(:field, field) |> Ash.Changeset.set_argument(:field, field)
|> Ash.Changeset.set_argument(:show_in_overview, show_in_overview) |> Ash.Changeset.set_argument(:show_in_overview, show_in_overview)
|> Ash.Changeset.set_argument(:required, required) |> Ash.Changeset.set_argument(:required, required)
|> Ash.Changeset.for_update(:update_single_member_field, %{}) |> Ash.Changeset.for_update(:update_single_member_field, %{})
|> Ash.update(domain: __MODULE__) |> Ash.update(domain: __MODULE__) do
{:ok, _} = result ->
SettingsCache.invalidate()
result
error ->
error
end
end end
@doc """ @doc """
@ -364,15 +400,131 @@ defmodule Mv.Membership do
- `:actor` - Must be nil for public submit (policy allows only unauthenticated). - `:actor` - Must be nil for public submit (policy allows only unauthenticated).
## Returns ## Returns
- `{:ok, request}` - Created JoinRequest in status pending_confirmation - `{:ok, request}` - Created JoinRequest in status pending_confirmation, email sent
- `{:ok, :notified_already_member}` - Email already a member; notice sent by email only (no request created)
- `{:ok, :notified_already_pending}` - Email already has pending/submitted request; notice or resend sent by email only
- `{:error, :email_delivery_failed}` - Request created but confirmation email could not be sent (logged)
- `{:error, error}` - Validation or authorization error - `{:error, error}` - Validation or authorization error
""" """
def submit_join_request(attrs, opts \\ []) do def submit_join_request(attrs, opts \\ []) do
actor = Keyword.get(opts, :actor) actor = Keyword.get(opts, :actor)
token = Map.get(attrs, :confirmation_token) || generate_confirmation_token() email = normalize_submit_email(attrs)
# Raw token is passed to the submit action; JoinRequest.Changes.SetConfirmationToken pending =
# hashes it before persist. Only the hash is stored; the raw token is sent in the email link. if email != nil and email != "", do: pending_join_request_with_email(email), else: nil
cond do
email != nil and email != "" and member_exists_with_email?(email) ->
send_already_member_and_return(email)
pending != nil ->
handle_already_pending(email, pending)
true ->
do_create_join_request(attrs, actor)
end
end
defp normalize_submit_email(attrs) do
raw = attrs["email"] || attrs[:email]
if is_binary(raw), do: String.trim(raw), else: nil
end
defp member_exists_with_email?(email) when is_binary(email) do
system_actor = SystemActor.get_system_actor()
opts = [actor: system_actor, domain: __MODULE__]
case Ash.get(Member, %{email: email}, opts) do
{:ok, _member} -> true
_ -> false
end
end
defp member_exists_with_email?(_), do: false
defp pending_join_request_with_email(email) when is_binary(email) do
system_actor = SystemActor.get_system_actor()
query =
JoinRequest
|> Ash.Query.filter(expr(email == ^email and status in [:pending_confirmation, :submitted]))
|> Ash.Query.sort(inserted_at: :desc)
|> Ash.Query.limit(1)
case Ash.read_one(query, actor: system_actor, domain: __MODULE__) do
{:ok, request} -> request
_ -> nil
end
end
defp pending_join_request_with_email(_), do: nil
defp join_notifier do
Application.get_env(:mv, :join_notifier, MvWeb.JoinNotifierImpl)
end
defp send_already_member_and_return(email) do
case join_notifier().send_already_member(email) do
{:ok, _} ->
:ok
{:error, reason} ->
Logger.error("Join already-member email failed for #{email}: #{inspect(reason)}")
end
# Delay is applied by the caller (e.g. JoinLive) to avoid blocking the process.
{:ok, :notified_already_member}
end
defp handle_already_pending(email, existing) do
if existing.status == :pending_confirmation do
resend_confirmation_to_pending(email, existing)
else
send_already_pending_and_return(email)
end
end
defp resend_confirmation_to_pending(email, request) do
new_token = generate_confirmation_token()
case request
|> Ash.Changeset.for_update(:regenerate_confirmation_token, %{
confirmation_token: new_token
})
|> Ash.update(domain: __MODULE__, authorize?: false) do
{:ok, _updated} ->
case join_notifier().send_confirmation(email, new_token, resend: true) do
{:ok, _} ->
:ok
{:error, reason} ->
Logger.error("Join resend confirmation email failed for #{email}: #{inspect(reason)}")
end
# Delay is applied by the caller (e.g. JoinLive) to avoid blocking the process.
{:ok, :notified_already_pending}
{:error, _} ->
# Fallback: do not create duplicate; send generic pending email
send_already_pending_and_return(email)
end
end
defp send_already_pending_and_return(email) do
case join_notifier().send_already_pending(email) do
{:ok, _} ->
:ok
{:error, reason} ->
Logger.error("Join already-pending email failed for #{email}: #{inspect(reason)}")
end
# Delay is applied by the caller (e.g. JoinLive) to avoid blocking the process.
{:ok, :notified_already_pending}
end
defp do_create_join_request(attrs, actor) do
token = Map.get(attrs, :confirmation_token) || generate_confirmation_token()
attrs_with_token = Map.put(attrs, :confirmation_token, token) attrs_with_token = Map.put(attrs, :confirmation_token, token)
case Ash.create(JoinRequest, attrs_with_token, case Ash.create(JoinRequest, attrs_with_token,
@ -381,8 +533,9 @@ defmodule Mv.Membership do
domain: __MODULE__ domain: __MODULE__
) do ) do
{:ok, request} -> {:ok, request} ->
case JoinConfirmationEmail.send(request.email, token) do case join_notifier().send_confirmation(request.email, token, []) do
{:ok, _email} -> {:ok, _email} ->
# Delay is applied by the caller (e.g. JoinLive) to avoid blocking the process.
{:ok, request} {:ok, request}
{:error, reason} -> {:error, reason} ->
@ -390,8 +543,7 @@ defmodule Mv.Membership do
"Join confirmation email failed for #{request.email}: #{inspect(reason)}" "Join confirmation email failed for #{request.email}: #{inspect(reason)}"
) )
# Request was created; return success so the user sees the confirmation message {:error, :email_delivery_failed}
{:ok, request}
end end
error -> error ->

View file

@ -15,6 +15,7 @@ defmodule Mv.Membership.Setting do
(e.g., `%{"first_name" => true, "last_name" => true}`). Email is always required; other fields default to optional. (e.g., `%{"first_name" => true, "last_name" => true}`). Email is always required; other fields default to optional.
- `include_joining_cycle` - Whether to include the joining cycle in membership fee generation (default: true) - `include_joining_cycle` - Whether to include the joining cycle in membership fee generation (default: true)
- `default_membership_fee_type_id` - Default membership fee type for new members (optional) - `default_membership_fee_type_id` - Default membership fee type for new members (optional)
- `registration_enabled` - Whether direct registration via /register is allowed (default: true)
- `join_form_enabled` - Whether the public /join page is active (default: false) - `join_form_enabled` - Whether the public /join page is active (default: false)
- `join_form_field_ids` - Ordered list of field IDs shown on the join form. Each entry is - `join_form_field_ids` - Ordered list of field IDs shown on the join form. Each entry is
either a member field name string (e.g. "email") or a custom field UUID. Email is always either a member field name string (e.g. "email") or a custom field UUID. Email is always
@ -129,6 +130,7 @@ defmodule Mv.Membership.Setting do
:smtp_ssl, :smtp_ssl,
:smtp_from_name, :smtp_from_name,
:smtp_from_email, :smtp_from_email,
:registration_enabled,
:join_form_enabled, :join_form_enabled,
:join_form_field_ids, :join_form_field_ids,
:join_form_field_required :join_form_field_required
@ -165,6 +167,7 @@ defmodule Mv.Membership.Setting do
:smtp_ssl, :smtp_ssl,
:smtp_from_name, :smtp_from_name,
:smtp_from_email, :smtp_from_email,
:registration_enabled,
:join_form_enabled, :join_form_enabled,
:join_form_field_ids, :join_form_field_ids,
:join_form_field_required :join_form_field_required
@ -514,6 +517,15 @@ defmodule Mv.Membership.Setting do
description "Email address for the transactional email sender. Must be owned by the SMTP user. Overrides MAIL_FROM_EMAIL env." description "Email address for the transactional email sender. Must be owned by the SMTP user. Overrides MAIL_FROM_EMAIL env."
end end
# Authentication: direct registration toggle
attribute :registration_enabled, :boolean do
allow_nil? false
default true
public? true
description "When true, users can register via /register; when false, only sign-in and join form remain available."
end
# Join form (Beitrittsformular) settings # Join form (Beitrittsformular) settings
attribute :join_form_enabled, :boolean do attribute :join_form_enabled, :boolean do
allow_nil? false allow_nil? false

View file

@ -0,0 +1,85 @@
defmodule Mv.Membership.SettingsCache do
@moduledoc """
Process-based cache for global settings to avoid repeated DB reads on hot paths
(e.g. RegistrationEnabled validation, Layouts.public_page, Plugs).
Uses a short TTL (default 60 seconds). Cache is invalidated on every settings
update so that changes take effect quickly. If no settings process exists
(e.g. in tests), get/1 falls back to direct read.
"""
use GenServer
@default_ttl_seconds 60
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@doc """
Returns cached settings or fetches and caches them. Uses TTL; invalidate on update.
"""
def get do
case Process.whereis(__MODULE__) do
nil ->
# No cache process (e.g. test) read directly
do_fetch()
_pid ->
GenServer.call(__MODULE__, :get, 10_000)
end
end
@doc """
Invalidates the cache so the next get/0 will refetch from the database.
Call after update_settings and any other path that mutates settings.
"""
def invalidate do
case Process.whereis(__MODULE__) do
nil -> :ok
_pid -> GenServer.cast(__MODULE__, :invalidate)
end
end
@impl true
def init(opts) do
ttl = Keyword.get(opts, :ttl_seconds, @default_ttl_seconds)
state = %{ttl_seconds: ttl, cached: nil, expires_at: nil}
{:ok, state}
end
@impl true
def handle_call(:get, _from, state) do
now = System.monotonic_time(:second)
expired? = state.expires_at == nil or state.expires_at <= now
{result, new_state} =
if expired? do
fetch_and_cache(now, state)
else
{{:ok, state.cached}, state}
end
{:reply, result, new_state}
end
defp fetch_and_cache(now, state) do
case do_fetch() do
{:ok, settings} = ok ->
expires = now + state.ttl_seconds
{ok, %{state | cached: settings, expires_at: expires}}
err ->
result = if state.cached, do: {:ok, state.cached}, else: err
{result, state}
end
end
@impl true
def handle_cast(:invalidate, state) do
{:noreply, %{state | cached: nil, expires_at: nil}}
end
defp do_fetch do
Mv.Membership.get_settings_uncached()
end
end

View file

@ -6,6 +6,7 @@ defmodule Mv.Application do
use Application use Application
alias Mv.Helpers.SystemActor alias Mv.Helpers.SystemActor
alias Mv.Membership.SettingsCache
alias Mv.Repo alias Mv.Repo
alias Mv.Vereinfacht.SyncFlash alias Mv.Vereinfacht.SyncFlash
alias MvWeb.Endpoint alias MvWeb.Endpoint
@ -16,9 +17,17 @@ defmodule Mv.Application do
def start(_type, _args) do def start(_type, _args) do
SyncFlash.create_table!() SyncFlash.create_table!()
children = [ # SettingsCache not started in test so get_settings runs in the test process (Ecto Sandbox).
cache_children =
if Application.get_env(:mv, :environment) == :test, do: [], else: [SettingsCache]
children =
[
Telemetry, Telemetry,
Repo, Repo
] ++
cache_children ++
[
{JoinRateLimit, [clean_period: :timer.minutes(1)]}, {JoinRateLimit, [clean_period: :timer.minutes(1)]},
{Task.Supervisor, name: Mv.TaskSupervisor}, {Task.Supervisor, name: Mv.TaskSupervisor},
{DNSCluster, query: Application.get_env(:mv, :dns_cluster_query) || :ignore}, {DNSCluster, query: Application.get_env(:mv, :dns_cluster_query) || :ignore},

View file

@ -3,52 +3,70 @@ defmodule MvWeb.AuthOverrides do
UI customizations for AshAuthentication Phoenix components. UI customizations for AshAuthentication Phoenix components.
## Overrides ## Overrides
- `SignIn` - Restricts form width to prevent full-width display - `SignIn` - Restricts form width and hides the library banner (title is rendered in SignInLive)
- `Banner` - Replaces default logo with "Mitgliederverwaltung" text - `Banner` - Replaces default logo with text for reset/confirm pages
- `HorizontalRule` - Translates "or" text to German - `Flash` - Hides library flash (we use flash_group in root layout)
## Documentation ## Documentation
For complete reference on available overrides, see: For complete reference on available overrides, see:
https://hexdocs.pm/ash_authentication_phoenix/ui-overrides.html https://hexdocs.pm/ash_authentication_phoenix/ui-overrides.html
""" """
use AshAuthentication.Phoenix.Overrides use AshAuthentication.Phoenix.Overrides
use Gettext, backend: MvWeb.Gettext
# configure your UI overrides here # Avoid full-width for the Sign In Form.
# Banner is hidden because SignInLive renders its own locale-aware title.
# First argument to `override` is the component name you are overriding.
# The body contains any number of configurations you wish to override
# Below are some examples
# For a complete reference, see https://hexdocs.pm/ash_authentication_phoenix/ui-overrides.html
# override AshAuthentication.Phoenix.Components.Banner do
# set :image_url, "https://media.giphy.com/media/g7GKcSzwQfugw/giphy.gif"
# set :text_class, "bg-red-500"
# end
# Avoid full-width for the Sign In Form
override AshAuthentication.Phoenix.Components.SignIn do override AshAuthentication.Phoenix.Components.SignIn do
set :root_class, "md:min-w-md" set :root_class, "md:min-w-md"
set :show_banner, false
end end
# Replace banner logo with text (no image in light or dark so link has discernible text) # Replace banner logo with text for reset/confirm pages (no image so link has discernible text).
override AshAuthentication.Phoenix.Components.Banner do override AshAuthentication.Phoenix.Components.Banner do
set :text, "Mitgliederverwaltung" set :text, "Mitgliederverwaltung"
set :image_url, nil set :image_url, nil
set :dark_image_url, nil set :dark_image_url, nil
end end
# Translate the "or" in the horizontal rule (between password form and SSO). # Hide AshAuthentication's Flash component since we use flash_group in root layout.
# Uses auth domain so it respects the current locale (e.g. "oder" in German). # This prevents duplicate flash messages.
override AshAuthentication.Phoenix.Components.HorizontalRule do
set :text, dgettext("auth", "or")
end
# Hide AshAuthentication's Flash component since we use flash_group in root layout
# This prevents duplicate flash messages
override AshAuthentication.Phoenix.Components.Flash do override AshAuthentication.Phoenix.Components.Flash do
set :message_class_info, "hidden" set :message_class_info, "hidden"
set :message_class_error, "hidden" set :message_class_error, "hidden"
end end
end end
defmodule MvWeb.AuthOverridesRegistrationDisabled do
@moduledoc """
When direct registration is disabled in global settings, this override is
prepended in SignInLive so the Password component hides the "Need an account?"
toggle (register_toggle_text: nil disables the register link per library docs).
"""
use AshAuthentication.Phoenix.Overrides
override AshAuthentication.Phoenix.Components.Password do
set :register_toggle_text, nil
end
end
defmodule MvWeb.AuthOverridesDE do
@moduledoc """
German locale-specific overrides for AshAuthentication Phoenix components.
Prepended to the overrides list in SignInLive when the locale is "de".
Provides runtime-static German text for components that do not use
the `_gettext` mechanism (e.g. HorizontalRule renders its text directly),
and for submit buttons whose disable_text bypasses the POT extraction pipeline.
"""
use AshAuthentication.Phoenix.Overrides
# HorizontalRule renders text without `_gettext`, so we need a static German string.
override AshAuthentication.Phoenix.Components.HorizontalRule do
set :text, "oder"
end
# Registering ... disable-text is passed through _gettext but "Registering ..."
# has no dgettext source reference, so we supply the German string directly.
override AshAuthentication.Phoenix.Components.Password.RegisterForm do
set :disable_button_text, "Registrieren..."
end
end

View file

@ -1295,6 +1295,41 @@ defmodule MvWeb.CoreComponents do
""" """
end end
@doc """
Renders a theme toggle using DaisyUI swap (sun/moon with rotate effect).
Wired to the theme script in root layout: checkbox uses `data-theme-toggle`,
root script syncs checked state (checked = dark) and listens for `phx:set-theme`.
Use in public header or sidebar. Optional `class` is applied to the wrapper.
"""
attr :class, :string, default: nil, doc: "Optional extra classes for the swap wrapper"
def theme_swap(assigns) do
assigns = assign(assigns, :wrapper_class, assigns[:class])
~H"""
<div class={[@wrapper_class]}>
<label
class="swap swap-rotate cursor-pointer focus-within:outline-none focus-within:focus-visible:ring-2 focus-within:focus-visible:ring-primary focus-within:focus-visible:ring-offset-2 rounded"
aria-label={gettext("Toggle dark mode")}
>
<input
type="checkbox"
data-theme-toggle
aria-label={gettext("Toggle dark mode")}
onchange="window.dispatchEvent(new CustomEvent('phx:set-theme',{detail:{theme:this.checked?'dark':'light'}}))"
/>
<span class="swap-on size-6 flex items-center justify-center" aria-hidden="true">
<.icon name="hero-moon" class="size-5" />
</span>
<span class="swap-off size-6 flex items-center justify-center" aria-hidden="true">
<.icon name="hero-sun" class="size-5" />
</span>
</label>
</div>
"""
end
@doc """ @doc """
Renders a [Heroicon](https://heroicons.com). Renders a [Heroicon](https://heroicons.com).

View file

@ -13,6 +13,98 @@ defmodule MvWeb.Layouts do
embed_templates "layouts/*" embed_templates "layouts/*"
@doc """
Builds the full browser tab title: "Mila", "Mila · Page", or "Mila · Page · Club".
Order is always: Mila · page title · club name.
Uses assigns[:club_name] and the short page label from assigns[:content_title] or
assigns[:page_title]. LiveViews should set content_title (same gettext as sidebar)
and then assign page_title to the result of this function so the client receives
the full title.
"""
def page_title_string(assigns) do
club = assigns[:club_name]
page = assigns[:content_title] || assigns[:page_title]
parts =
[page, club]
|> Enum.filter(&(is_binary(&1) and String.trim(&1) != ""))
if parts == [] do
"Mila"
else
"Mila · " <> Enum.join(parts, " · ")
end
end
@doc """
Assigns content_title (short label for heading; same gettext as sidebar) and
page_title (full browser tab title). Call from LiveView mount after club_name
is set (e.g. from on_mount). Returns the socket.
"""
def assign_page_title(socket, content_title) do
socket = assign(socket, :content_title, content_title)
assign(socket, :page_title, page_title_string(socket.assigns))
end
@doc """
Renders the public (unauthenticated) page layout: header with logo + "Mitgliederverwaltung" left,
club name centered, language selector right; plus main content and flash group. Use for sign-in, join, and join-confirm pages so they
share the same chrome without the sidebar or authenticated layout logic.
Pass optional `:club_name` from the parent (e.g. LiveView mount) to avoid a settings read in the component.
"""
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :club_name, :string,
default: nil,
doc: "optional; if set, avoids get_settings() in the component"
slot :inner_block, required: true
def public_page(assigns) do
club_name =
assigns[:club_name] ||
case Mv.Membership.get_settings() do
{:ok, s} -> s.club_name || "Mitgliederverwaltung"
_ -> "Mitgliederverwaltung"
end
assigns = assign(assigns, :club_name, club_name)
~H"""
<header class="relative flex items-center justify-between p-4 border-b border-base-300 bg-base-100">
<div class="flex items-center gap-3 shrink-0 min-w-0 max-w-[45%]">
<img src={~p"/images/mila.svg"} alt="Mila Logo" class="size-8 shrink-0" />
<span class="text-lg font-bold truncate">Mitgliederverwaltung</span>
</div>
<span class="absolute left-1/2 -translate-x-1/2 text-lg font-bold text-center max-w-[50%] truncate">
{@club_name}
</span>
<div class="shrink-0 flex items-center gap-2">
<form method="post" action={~p"/set_locale"}>
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
<select
name="locale"
onchange="this.form.submit()"
class="select select-sm focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
aria-label={gettext("Select language")}
>
<option value="de" selected={Gettext.get_locale(MvWeb.Gettext) == "de"}>Deutsch</option>
<option value="en" selected={Gettext.get_locale(MvWeb.Gettext) == "en"}>English</option>
</select>
</form>
<.theme_swap />
</div>
</header>
<main class="px-4 py-8 sm:px-6">
<div class="mx-auto max-full space-y-4">
{render_slot(@inner_block)}
</div>
</main>
<.flash_group flash={@flash} />
"""
end
@doc """ @doc """
Renders the app layout. Can be used with or without a current_user. Renders the app layout. Can be used with or without a current_user.
When current_user is present, it will show the navigation bar. When current_user is present, it will show the navigation bar.
@ -99,13 +191,17 @@ defmodule MvWeb.Layouts do
</div> </div>
</div> </div>
<% else %> <% else %>
<!-- Unauthenticated: simple header (logo, club name, language selector; same classes as sidebar header) --> <!-- Unauthenticated: Option 3 header (logo + app name left, club name center, language selector right) -->
<header class="flex items-center gap-3 p-4 border-b border-base-300 bg-base-100"> <header class="relative flex items-center justify-between p-4 border-b border-base-300 bg-base-100">
<div class="flex items-center gap-3 shrink-0 min-w-0 max-w-[45%]">
<img src={~p"/images/mila.svg"} alt="Mila Logo" class="size-8 shrink-0" /> <img src={~p"/images/mila.svg"} alt="Mila Logo" class="size-8 shrink-0" />
<span class="menu-label text-lg font-bold truncate flex-1"> <span class="menu-label text-lg font-bold truncate">Mitgliederverwaltung</span>
</div>
<span class="absolute left-1/2 -translate-x-1/2 text-lg font-bold text-center max-w-[50%] truncate">
{@club_name} {@club_name}
</span> </span>
<form method="post" action={~p"/set_locale"} class="shrink-0"> <div class="shrink-0 flex items-center gap-2">
<form method="post" action={~p"/set_locale"}>
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} /> <input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
<select <select
name="locale" name="locale"
@ -113,10 +209,12 @@ defmodule MvWeb.Layouts do
class="select select-sm focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2" class="select select-sm focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
aria-label={gettext("Select language")} aria-label={gettext("Select language")}
> >
<option value="de" selected={Gettext.get_locale() == "de"}>Deutsch</option> <option value="de" selected={Gettext.get_locale(MvWeb.Gettext) == "de"}>Deutsch</option>
<option value="en" selected={Gettext.get_locale() == "en"}>English</option> <option value="en" selected={Gettext.get_locale(MvWeb.Gettext) == "en"}>English</option>
</select> </select>
</form> </form>
<.theme_swap />
</div>
</header> </header>
<main class="px-4 py-8 sm:px-6"> <main class="px-4 py-8 sm:px-6">
<div class="mx-auto space-y-4 max-full"> <div class="mx-auto space-y-4 max-full">

View file

@ -7,8 +7,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content={get_csrf_token()} /> <meta name="csrf-token" content={get_csrf_token()} />
<link phx-track-static rel="icon" type="image/svg+xml" href={~p"/images/mila.svg"} /> <link phx-track-static rel="icon" type="image/svg+xml" href={~p"/images/mila.svg"} />
<.live_title default="Mv" suffix=" · Phoenix Framework"> <.live_title default="Mila">
{assigns[:page_title]} {page_title_string(assigns)}
</.live_title> </.live_title>
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} /> <link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
<script defer phx-track-static type="text/javascript" src={~p"/assets/js/app.js"}> <script defer phx-track-static type="text/javascript" src={~p"/assets/js/app.js"}>

View file

@ -251,8 +251,10 @@ defmodule MvWeb.Layouts.Sidebar do
defp sidebar_footer(assigns) do defp sidebar_footer(assigns) do
~H""" ~H"""
<div class="mt-auto p-4 border-t border-base-300 space-y-4"> <div class="mt-auto p-4 border-t border-base-300 space-y-4">
<!-- Language Selector (nur expanded) --> <!-- Theme swap + Language selector in one row (theme left, language right when expanded) -->
<form method="post" action={~p"/set_locale"} class="expanded-only"> <div class="flex items-center gap-2">
<.theme_swap />
<form method="post" action={~p"/set_locale"} class="expanded-only flex-1 min-w-0">
<input type="hidden" name="_csrf_token" value={get_csrf_token()} /> <input type="hidden" name="_csrf_token" value={get_csrf_token()} />
<select <select
name="locale" name="locale"
@ -260,12 +262,11 @@ defmodule MvWeb.Layouts.Sidebar do
class="select select-sm w-full focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2" class="select select-sm w-full focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
aria-label={gettext("Select language")} aria-label={gettext("Select language")}
> >
<option value="de" selected={Gettext.get_locale() == "de"}>Deutsch</option> <option value="de" selected={Gettext.get_locale(MvWeb.Gettext) == "de"}>Deutsch</option>
<option value="en" selected={Gettext.get_locale() == "en"}>English</option> <option value="en" selected={Gettext.get_locale(MvWeb.Gettext) == "en"}>English</option>
</select> </select>
</form> </form>
<!-- Theme Toggle (immer sichtbar) --> </div>
<.theme_toggle />
<!-- User Menu (nur wenn current_user existiert) --> <!-- User Menu (nur wenn current_user existiert) -->
<%= if @current_user do %> <%= if @current_user do %>
<.user_menu current_user={@current_user} /> <.user_menu current_user={@current_user} />
@ -274,29 +275,6 @@ defmodule MvWeb.Layouts.Sidebar do
""" """
end end
defp theme_toggle(assigns) do
~H"""
<label
class="flex items-center gap-2 cursor-pointer justify-center focus-within:outline-none focus-within:ring-2 focus-within:ring-primary focus-within:ring-offset-2"
aria-label={gettext("Toggle dark mode")}
>
<.icon name="hero-sun" class="size-5" aria-hidden="true" />
<div id="theme-toggle" phx-update="ignore">
<input
id="theme-toggle-input"
type="checkbox"
class="toggle toggle-sm focus:outline-none"
data-theme-toggle
onchange="window.dispatchEvent(new CustomEvent('phx:set-theme',{detail:{theme:this.checked?'dark':'light'}}))"
aria-label={gettext("Toggle dark mode")}
/>
</div>
<.icon name="hero-moon" class="size-5" aria-hidden="true" />
</label>
"""
end
attr :current_user, :map, default: nil, doc: "The current user" attr :current_user, :map, default: nil, doc: "The current user"
defp user_menu(assigns) do defp user_menu(assigns) do

View file

@ -2,11 +2,14 @@ defmodule MvWeb.JoinConfirmController do
@moduledoc """ @moduledoc """
Handles GET /confirm_join/:token for the public join flow (double opt-in). 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 Renders a full HTML page with public header and hero layout (success, expired,
dependency. Public route; no authentication required. or invalid). Calls a configurable callback (default Mv.Membership) so tests can
stub the dependency. Public route; no authentication required.
""" """
use MvWeb, :controller use MvWeb, :controller
use Gettext, backend: MvWeb.Gettext
def confirm(conn, %{"token" => token}) when is_binary(token) do def confirm(conn, %{"token" => token}) when is_binary(token) do
callback = Application.get_env(:mv, :join_confirm_callback, Mv.Membership) callback = Application.get_env(:mv, :join_confirm_callback, Mv.Membership)
@ -26,20 +29,36 @@ defmodule MvWeb.JoinConfirmController do
defp success_response(conn) do defp success_response(conn) do
conn conn
|> put_resp_content_type("text/html") |> assign_confirm_assigns(:success)
|> send_resp(200, gettext("Thank you, we have received your request.")) |> put_view(MvWeb.JoinConfirmHTML)
|> render("confirm.html")
end end
defp expired_response(conn) do defp expired_response(conn) do
conn conn
|> put_resp_content_type("text/html") |> assign_confirm_assigns(:expired)
|> send_resp(200, gettext("This link has expired. Please submit the form again.")) |> put_view(MvWeb.JoinConfirmHTML)
|> render("confirm.html")
end end
defp invalid_response(conn) do defp invalid_response(conn) do
conn conn
|> put_resp_content_type("text/html")
|> put_status(404) |> put_status(404)
|> send_resp(404, gettext("Invalid or expired link.")) |> assign_confirm_assigns(:invalid)
|> put_view(MvWeb.JoinConfirmHTML)
|> render("confirm.html")
end end
defp assign_confirm_assigns(conn, result) do
page_title = page_title_for_result(result)
conn
|> assign(:result, result)
|> assign(:page_title, page_title)
|> assign(:flash, conn.assigns[:flash] || conn.flash || %{})
end
defp page_title_for_result(:success), do: gettext("Join confirmation")
defp page_title_for_result(:expired), do: gettext("Link expired")
defp page_title_for_result(:invalid), do: gettext("Invalid link")
end end

View file

@ -0,0 +1,9 @@
defmodule MvWeb.JoinConfirmHTML do
@moduledoc """
Renders join confirmation result pages (success, expired, invalid) with
public header and hero layout. Used by JoinConfirmController.
"""
use MvWeb, :html
embed_templates "join_confirm_html/*"
end

View file

@ -0,0 +1,45 @@
<Layouts.public_page flash={@flash}>
<div class="max-w-4xl mx-auto">
<div class="hero min-h-[60vh] bg-base-200 rounded-lg">
<div class="hero-content flex-col items-start text-left">
<div class="max-w-md">
<%= case @result do %>
<% :success -> %>
<h1 class="text-3xl font-bold">
{gettext("Thank you")}
</h1>
<p class="py-4 text-base-content/80">
{gettext("Thank you, we have received your request.")}
</p>
<p class="text-sm text-base-content/70">
{gettext("You will receive an email once your application has been reviewed.")}
</p>
<a href={~p"/join"} class="btn btn-primary mt-4">
{gettext("Back to join form")}
</a>
<% :expired -> %>
<h1 class="text-3xl font-bold">
{gettext("Link expired")}
</h1>
<p class="py-4 text-base-content/80">
{gettext("This link has expired. Please submit the form again.")}
</p>
<a href={~p"/join"} class="btn btn-primary mt-4">
{gettext("Submit new request")}
</a>
<% :invalid -> %>
<h1 class="text-3xl font-bold text-error">
{gettext("Invalid or expired link")}
</h1>
<p class="py-4 text-base-content/80">
{gettext("Invalid or expired link.")}
</p>
<a href={~p"/join"} class="btn btn-primary mt-4">
{gettext("Go to join form")}
</a>
<% end %>
</div>
</div>
</div>
</div>
</Layouts.public_page>

View file

@ -7,7 +7,11 @@ defmodule MvWeb.PageController do
""" """
use MvWeb, :controller use MvWeb, :controller
use Gettext, backend: MvWeb.Gettext
def home(conn, _params) do def home(conn, _params) do
render(conn, :home) conn
|> assign(:page_title, gettext("Home"))
|> render(:home)
end end
end end

View file

@ -0,0 +1,42 @@
defmodule MvWeb.Emails.JoinAlreadyMemberEmail do
@moduledoc """
Sends an email when someone submits the join form with an address that is already a member.
Used for anti-enumeration: the UI shows the same success message; only the email
informs the recipient. Uses 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 "already a member" notice to the given address.
Returns `{:ok, email}` on success, `{:error, reason}` on delivery failure.
"""
def send(email_address) when is_binary(email_address) do
subject = gettext("Membership application already a member")
assigns = %{
subject: subject,
app_name: Mailer.mail_from() |> elem(0),
locale: Gettext.get_locale(MvWeb.Gettext)
}
email =
new()
|> from(Mailer.mail_from())
|> to(email_address)
|> subject(subject)
|> put_view(MvWeb.EmailsView)
|> render_body("join_already_member.html", assigns)
Mailer.deliver(email, Mailer.smtp_config())
end
end

View file

@ -0,0 +1,43 @@
defmodule MvWeb.Emails.JoinAlreadyPendingEmail do
@moduledoc """
Sends an email when someone submits the join form with an address that already
has a submitted (confirmed) application under review.
Used for anti-enumeration: the UI shows the same success message; only the email
informs the recipient. Uses 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 "application already under review" notice to the given address.
Returns `{:ok, email}` on success, `{:error, reason}` on delivery failure.
"""
def send(email_address) when is_binary(email_address) do
subject = gettext("Membership application already under review")
assigns = %{
subject: subject,
app_name: Mailer.mail_from() |> elem(0),
locale: Gettext.get_locale(MvWeb.Gettext)
}
email =
new()
|> from(Mailer.mail_from())
|> to(email_address)
|> subject(subject)
|> put_view(MvWeb.EmailsView)
|> render_body("join_already_pending.html", assigns)
Mailer.deliver(email, Mailer.smtp_config())
end
end

View file

@ -15,13 +15,19 @@ defmodule MvWeb.Emails.JoinConfirmationEmail do
@doc """ @doc """
Sends the join confirmation email to the given address with the confirmation link. Sends the join confirmation email to the given address with the confirmation link.
Called from the domain after a JoinRequest is created (submit flow). Uses the same SMTP configuration as the test mail (Settings or boot ENV) via
`Mailer.deliver/2` with `Mailer.smtp_config/0` for consistency.
Called from the domain after a JoinRequest is created (submit flow) or when
resending to an existing pending request.
## Options
- `:resend` - If true, adds a short note that the link is being sent again for an existing request.
Returns `{:ok, email}` on success, `{:error, reason}` on delivery failure. 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 def send(email_address, token, opts \\ [])
when is_binary(email_address) and is_binary(token) do
confirm_url = url(~p"/confirm_join/#{token}") confirm_url = url(~p"/confirm_join/#{token}")
subject = gettext("Confirm your membership request") subject = gettext("Confirm your membership request")
@ -29,15 +35,18 @@ defmodule MvWeb.Emails.JoinConfirmationEmail do
confirm_url: confirm_url, confirm_url: confirm_url,
subject: subject, subject: subject,
app_name: Mailer.mail_from() |> elem(0), app_name: Mailer.mail_from() |> elem(0),
locale: Gettext.get_locale(MvWeb.Gettext) locale: Gettext.get_locale(MvWeb.Gettext),
resend: Keyword.get(opts, :resend, false)
} }
email =
new() new()
|> from(Mailer.mail_from()) |> from(Mailer.mail_from())
|> to(email_address) |> to(email_address)
|> subject(subject) |> subject(subject)
|> put_view(MvWeb.EmailsView) |> put_view(MvWeb.EmailsView)
|> render_body("join_confirmation.html", assigns) |> render_body("join_confirmation.html", assigns)
|> Mailer.deliver()
Mailer.deliver(email, Mailer.smtp_config())
end end
end end

View file

@ -2,6 +2,7 @@ defmodule MvWeb.Helpers.DateFormatter do
@moduledoc """ @moduledoc """
Centralized date formatting helper for the application. Centralized date formatting helper for the application.
Formats dates in European format (dd.mm.yyyy). Formats dates in European format (dd.mm.yyyy).
DateTime can be shown in UTC or in a given IANA timezone (e.g. from browser).
""" """
use Gettext, backend: MvWeb.Gettext use Gettext, backend: MvWeb.Gettext
@ -28,19 +29,40 @@ defmodule MvWeb.Helpers.DateFormatter do
@doc """ @doc """
Formats a DateTime struct to European format (dd.mm.yyyy HH:MM). Formats a DateTime struct to European format (dd.mm.yyyy HH:MM).
When `timezone` is a valid IANA timezone string (e.g. from the browser),
the datetime is converted to that zone before formatting. When `timezone` is
nil or invalid, the datetime is formatted in UTC.
## Examples ## Examples
iex> MvWeb.Helpers.DateFormatter.format_datetime(~U[2024-03-15 10:30:00Z]) iex> MvWeb.Helpers.DateFormatter.format_datetime(~U[2024-03-15 10:30:00Z])
"15.03.2024 10:30" "15.03.2024 10:30"
iex> MvWeb.Helpers.DateFormatter.format_datetime(~U[2024-03-15 10:30:00Z], "Europe/Berlin")
"15.03.2024 11:30"
iex> MvWeb.Helpers.DateFormatter.format_datetime(nil) iex> MvWeb.Helpers.DateFormatter.format_datetime(nil)
"" ""
""" """
def format_datetime(%DateTime{} = dt) do def format_datetime(%DateTime{} = dt), do: format_datetime(dt, nil)
Calendar.strftime(dt, "%d.%m.%Y %H:%M") def format_datetime(nil), do: ""
def format_datetime(_), do: "Invalid datetime"
def format_datetime(%DateTime{} = dt, nil), do: format_datetime_utc(dt)
def format_datetime(%DateTime{} = dt, ""), do: format_datetime_utc(dt)
def format_datetime(%DateTime{} = dt, tz) when is_binary(tz) do
case DateTime.shift_zone(dt, tz, Tz.TimeZoneDatabase) do
{:ok, shifted} -> Calendar.strftime(shifted, "%d.%m.%Y %H:%M")
{:error, _} -> format_datetime_utc(dt)
end
end end
def format_datetime(nil), do: "" def format_datetime(nil, _timezone), do: ""
def format_datetime(_), do: "Invalid datetime" def format_datetime(_, _timezone), do: "Invalid datetime"
defp format_datetime_utc(%DateTime{} = dt) do
Calendar.strftime(dt, "%d.%m.%Y %H:%M")
end
end end

View file

@ -0,0 +1,25 @@
defmodule MvWeb.JoinNotifierImpl do
@moduledoc """
Default implementation of Mv.Membership.JoinNotifier that delegates to MvWeb.Emails.
"""
@behaviour Mv.Membership.JoinNotifier
alias MvWeb.Emails.JoinAlreadyMemberEmail
alias MvWeb.Emails.JoinAlreadyPendingEmail
alias MvWeb.Emails.JoinConfirmationEmail
@impl true
def send_confirmation(email, token, opts \\ []) do
JoinConfirmationEmail.send(email, token, opts)
end
@impl true
def send_already_member(email) do
JoinAlreadyMemberEmail.send(email)
end
@impl true
def send_already_pending(email) do
JoinAlreadyPendingEmail.send(email)
end
end

View file

@ -1,28 +1,61 @@
defmodule MvWeb.SignInLive do defmodule MvWeb.SignInLive do
@moduledoc """ @moduledoc """
Custom sign-in page with language selector and conditional Single Sign-On button. Custom sign-in page with public header and hero layout (same as Join/Join Confirm).
- Renders a language selector (same pattern as LinkOidcAccountLive). Uses Layouts.public_page (no sidebar, no app-layout hooks). Wraps the AshAuthentication
- Wraps the default AshAuthentication SignIn component in a container with SignIn component in a hero section. Container has data-oidc-configured so CSS can hide
`data-oidc-configured` so that CSS can hide the SSO button when OIDC is not configured. the SSO button when OIDC is not configured.
Keeps `use Phoenix.LiveView` (not MvWeb :live_view) so AshAuthentication's sign_in_route
live_session on_mount chain is not mixed with LiveHelpers hooks.
## Locale overrides
`MvWeb.AuthOverridesDE` is prepended to the overrides list when the locale is "de",
providing static German strings for components that do not use `_gettext` internally
(e.g. HorizontalRule renders its `:text` override directly).
""" """
use Phoenix.LiveView use Phoenix.LiveView
use Gettext, backend: MvWeb.Gettext use Gettext, backend: MvWeb.Gettext
alias AshAuthentication.Phoenix.Components alias AshAuthentication.Phoenix.Components
alias Mv.Config alias Mv.Config
alias Mv.Membership
alias MvWeb.{AuthOverridesDE, AuthOverridesRegistrationDisabled, Layouts}
@impl true @impl true
def mount(_params, session, socket) do def mount(_params, session, socket) do
overrides =
session
|> Map.get("overrides", [AshAuthentication.Phoenix.Overrides.Default])
# Locale: same fallback as LiveUserAuth so config :default_locale (e.g. "en" in test) is respected # Locale: same fallback as LiveUserAuth so config :default_locale (e.g. "en" in test) is respected
locale = locale = session["locale"] || Application.get_env(:mv, :default_locale, "de")
session["locale"] || Application.get_env(:mv, :default_locale, "de")
# Set both backend-specific and global locale so Gettext.get_locale/0 and
# Gettext.get_locale/1 both return the correct value (important for the
# language-selector `selected` attribute in Layouts.public_page).
Gettext.put_locale(MvWeb.Gettext, locale) Gettext.put_locale(MvWeb.Gettext, locale)
Gettext.put_locale(locale)
# Prepend DE-specific overrides when locale is German so that components
# without _gettext support (e.g. HorizontalRule) still render in German.
base_overrides = Map.get(session, "overrides", [AshAuthentication.Phoenix.Overrides.Default])
locale_overrides = if locale == "de", do: [AuthOverridesDE], else: []
registration_disabled =
if session["registration_enabled"] == false,
do: [AuthOverridesRegistrationDisabled],
else: []
# When registration is disabled: hide register link (register_path: nil) and hide
# "Need an account?" toggle (override register_toggle_text: nil so it takes precedence).
overrides = registration_disabled ++ locale_overrides ++ base_overrides
register_path =
if session["registration_enabled"] == false, do: nil, else: session["register_path"]
# Club name and page title for browser tab (root layout: Mila · Club · Page)
club_name =
case Membership.get_settings() do
{:ok, settings} when is_binary(settings.club_name) -> settings.club_name
_ -> nil
end
socket = socket =
socket socket
@ -30,18 +63,19 @@ defmodule MvWeb.SignInLive do
|> assign_new(:otp_app, fn -> nil end) |> assign_new(:otp_app, fn -> nil end)
|> assign(:path, session["path"] || "/") |> assign(:path, session["path"] || "/")
|> assign(:reset_path, session["reset_path"]) |> assign(:reset_path, session["reset_path"])
|> assign(:register_path, session["register_path"]) |> assign(:register_path, register_path)
|> assign(:current_tenant, session["tenant"]) |> assign(:current_tenant, session["tenant"])
|> assign(:resources, session["resources"]) |> assign(:resources, session["resources"])
|> assign(:context, session["context"] || %{}) |> assign(:context, session["context"] || %{})
|> assign(:auth_routes_prefix, session["auth_routes_prefix"]) |> assign(:auth_routes_prefix, session["auth_routes_prefix"])
|> assign(:gettext_fn, session["gettext_fn"]) |> assign(:gettext_fn, session["gettext_fn"])
|> assign(:live_action, :sign_in) |> assign_new(:live_action, fn -> :sign_in end)
|> assign(:oidc_configured, Config.oidc_configured?()) |> assign(:oidc_configured, Config.oidc_configured?())
|> assign(:oidc_only, Config.oidc_only?()) |> assign(:oidc_only, Config.oidc_only?())
|> assign(:root_class, "grid h-screen place-items-center bg-base-100")
|> assign(:sign_in_id, "sign-in") |> assign(:sign_in_id, "sign-in")
|> assign(:locale, locale) |> assign(:locale, locale)
|> assign(:club_name, club_name)
|> Layouts.assign_page_title(gettext("Sign in"))
{:ok, socket} {:ok, socket}
end end
@ -54,34 +88,23 @@ defmodule MvWeb.SignInLive do
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<main <Layouts.public_page flash={@flash}>
<div class="max-w-4xl mx-auto">
<div
class="hero min-h-[60vh] bg-base-200 rounded-lg"
id="sign-in-page" id="sign-in-page"
role="main" role="main"
class={@root_class}
data-oidc-configured={to_string(@oidc_configured)} data-oidc-configured={to_string(@oidc_configured)}
data-oidc-only={to_string(@oidc_only)} data-oidc-only={to_string(@oidc_only)}
data-locale={@locale} data-locale={@locale}
> >
<h1 class="sr-only">{dgettext("auth", "Sign in")}</h1> <div class="hero-content flex-col items-start text-left">
<%!-- Language selector --%> <div class="w-full max-w-md">
<nav <h1 class="text-xl font-semibold leading-8">
aria-label={dgettext("auth", "Language selection")} {if @live_action == :register,
class="absolute top-4 right-4 flex justify-end z-10" do: dgettext("auth", "Register"),
> else: dgettext("auth", "Sign in")}
<form method="post" action="/set_locale" class="text-sm"> </h1>
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
<select
name="locale"
onchange="this.form.submit()"
class="select select-sm select-bordered bg-base-100"
aria-label={dgettext("auth", "Select language")}
>
<option value="de" selected={@locale == "de"}>Deutsch</option>
<option value="en" selected={@locale == "en"}>English</option>
</select>
</form>
</nav>
<.live_component <.live_component
module={Components.SignIn} module={Components.SignIn}
otp_app={@otp_app} otp_app={@otp_app}
@ -97,7 +120,11 @@ defmodule MvWeb.SignInLive do
context={@context} context={@context}
gettext_fn={@gettext_fn} gettext_fn={@gettext_fn}
/> />
</main> </div>
</div>
</div>
</div>
</Layouts.public_page>
""" """
end end
end end

View file

@ -17,7 +17,7 @@ defmodule MvWeb.DatafieldsLive do
{:ok, {:ok,
socket socket
|> assign(:page_title, gettext("Datafields")) |> Layouts.assign_page_title(gettext("Datafields"))
|> assign(:settings, settings) |> assign(:settings, settings)
|> assign(:active_editing_section, nil) |> assign(:active_editing_section, nil)
|> assign(:custom_field_delete_modal_open, false)} |> assign(:custom_field_delete_modal_open, false)}
@ -50,7 +50,7 @@ defmodule MvWeb.DatafieldsLive do
~H""" ~H"""
<Layouts.app flash={@flash} current_user={@current_user} club_name={@settings.club_name}> <Layouts.app flash={@flash} current_user={@current_user} club_name={@settings.club_name}>
<.header> <.header>
{gettext("Datafields")} {@content_title}
<:subtitle> <:subtitle>
{gettext( {gettext(
"Configure which data you want to save for your members. Define individual datafields." "Configure which data you want to save for your members. Define individual datafields."

View file

@ -11,12 +11,14 @@ defmodule MvWeb.GlobalSettingsLive do
## Settings ## Settings
- `club_name` - The name of the association/club (required) - `club_name` - The name of the association/club (required)
- `registration_enabled` - Whether direct registration via /register is allowed
- `join_form_enabled` - Whether the public /join page is active - `join_form_enabled` - Whether the public /join page is active
- `join_form_field_ids` - Ordered list of field IDs shown on the join form - `join_form_field_ids` - Ordered list of field IDs shown on the join form
- `join_form_field_required` - Map of field ID => required boolean - `join_form_field_required` - Map of field ID => required boolean
## Events ## Events
- `validate` / `save` - Club settings form - `validate` / `save` - Club settings form
- `toggle_registration_enabled` - Enable/disable direct registration (/register)
- `toggle_join_form_enabled` - Enable/disable the join form - `toggle_join_form_enabled` - Enable/disable the join form
- `add_join_form_field` / `remove_join_form_field` - Manage join form fields - `add_join_form_field` / `remove_join_form_field` - Manage join form fields
- `toggle_join_form_field_required` - Toggle required flag per field - `toggle_join_form_field_required` - Toggle required flag per field
@ -58,7 +60,7 @@ defmodule MvWeb.GlobalSettingsLive do
socket = socket =
socket socket
|> assign(:page_title, gettext("Settings")) |> Layouts.assign_page_title(gettext("Basic settings"))
|> assign(:settings, settings) |> assign(:settings, settings)
|> assign(:locale, locale) |> assign(:locale, locale)
|> assign(:environment, environment) |> assign(:environment, environment)
@ -80,6 +82,7 @@ defmodule MvWeb.GlobalSettingsLive do
|> assign(:oidc_only_env_set, Mv.Config.oidc_only_env_set?()) |> assign(:oidc_only_env_set, Mv.Config.oidc_only_env_set?())
|> assign(:oidc_configured, Mv.Config.oidc_configured?()) |> assign(:oidc_configured, Mv.Config.oidc_configured?())
|> assign(:oidc_client_secret_set, Mv.Config.oidc_client_secret_set?()) |> assign(:oidc_client_secret_set, Mv.Config.oidc_client_secret_set?())
|> assign(:registration_enabled, settings.registration_enabled != false)
|> assign(:smtp_env_configured, Mv.Config.smtp_env_configured?()) |> assign(:smtp_env_configured, Mv.Config.smtp_env_configured?())
|> assign(:smtp_host_env_set, Mv.Config.smtp_host_env_set?()) |> assign(:smtp_host_env_set, Mv.Config.smtp_host_env_set?())
|> assign(:smtp_port_env_set, Mv.Config.smtp_port_env_set?()) |> assign(:smtp_port_env_set, Mv.Config.smtp_port_env_set?())
@ -93,6 +96,7 @@ defmodule MvWeb.GlobalSettingsLive do
|> assign(:smtp_test_result, nil) |> assign(:smtp_test_result, nil)
|> assign(:smtp_test_to_email, "") |> assign(:smtp_test_to_email, "")
|> assign_join_form_state(settings, custom_fields) |> assign_join_form_state(settings, custom_fields)
|> assign(:join_url, url(socket.endpoint, ~p"/join"))
|> assign_form() |> assign_form()
{:ok, socket} {:ok, socket}
@ -108,12 +112,13 @@ defmodule MvWeb.GlobalSettingsLive do
~H""" ~H"""
<Layouts.app flash={@flash} current_user={@current_user} club_name={@settings.club_name}> <Layouts.app flash={@flash} current_user={@current_user} club_name={@settings.club_name}>
<.header> <.header>
{gettext("Settings")} {gettext("Basic settings")}
<:subtitle> <:subtitle>
{gettext("Manage global settings for the association.")} {gettext("Manage global settings for the association.")}
</:subtitle> </:subtitle>
</.header> </.header>
<div class="mt-6 space-y-6 max-w-4xl px-4">
<%!-- Club Settings Section --%> <%!-- Club Settings Section --%>
<.form_section title={gettext("Club Settings")}> <.form_section title={gettext("Club Settings")}>
<.form for={@form} id="settings-form" phx-change="validate" phx-submit="save"> <.form for={@form} id="settings-form" phx-change="validate" phx-submit="save">
@ -134,7 +139,9 @@ defmodule MvWeb.GlobalSettingsLive do
<%!-- Join Form Section (Beitrittsformular) --%> <%!-- Join Form Section (Beitrittsformular) --%>
<.form_section title={gettext("Join Form")}> <.form_section title={gettext("Join Form")}>
<p class="text-sm text-base-content/70 mb-4"> <p class="text-sm text-base-content/70 mb-4">
{gettext("Configure the public join form that allows new members to submit a join request.")} {gettext(
"Configure the public join form that allows new members to submit a join request."
)}
</p> </p>
<%!-- Enable/disable --%> <%!-- Enable/disable --%>
@ -153,6 +160,33 @@ defmodule MvWeb.GlobalSettingsLive do
</div> </div>
<div :if={@join_form_enabled}> <div :if={@join_form_enabled}>
<%!-- Copyable join page link (below checkbox, above field list) --%>
<div class="mb-4 p-3 rounded-lg border border-base-300 bg-base-200/50">
<p class="text-sm text-base-content/70 mb-2">
{gettext("Link to the public join page (share this with applicants):")}
</p>
<div class="flex flex-wrap items-center gap-2">
<input
type="text"
readonly
value={@join_url}
class="input input-bordered input-sm flex-1 min-w-0 font-mono text-sm"
aria-label={gettext("Join page URL")}
/>
<.button
variant="secondary"
size="sm"
id="copy-join-url-btn"
phx-hook="CopyToClipboard"
phx-click="copy_join_url"
aria-label={gettext("Copy join page URL")}
>
<.icon name="hero-clipboard-document" class="size-4" />
{gettext("Copy")}
</.button>
</div>
</div>
<%!-- Field list header + Add button (left-aligned) --%> <%!-- Field list header + Add button (left-aligned) --%>
<h3 class="font-medium mb-3">{gettext("Fields on the join form")}</h3> <h3 class="font-medium mb-3">{gettext("Fields on the join form")}</h3>
<div class="relative mb-3 w-fit" phx-click-away="hide_add_field_dropdown"> <div class="relative mb-3 w-fit" phx-click-away="hide_add_field_dropdown">
@ -225,7 +259,7 @@ defmodule MvWeb.GlobalSettingsLive do
</p> </p>
<%!-- Fields table (compact width, reorderable) --%> <%!-- Fields table (compact width, reorderable) --%>
<div :if={not Enum.empty?(@join_form_fields)} class="mb-4 max-w-2xl"> <div :if={not Enum.empty?(@join_form_fields)} class="mb-4">
<.sortable_table <.sortable_table
id="join-form-fields-table" id="join-form-fields-table"
rows={@join_form_fields} rows={@join_form_fields}
@ -235,7 +269,11 @@ defmodule MvWeb.GlobalSettingsLive do
<:col :let={field} label={gettext("Field")} class="min-w-[14rem]"> <:col :let={field} label={gettext("Field")} class="min-w-[14rem]">
{field.label} {field.label}
</:col> </:col>
<:col :let={field} label={gettext("Required")} class="w-24 max-w-[9.375rem] text-center"> <:col
:let={field}
label={gettext("Required")}
class="w-24 max-w-[9.375rem] text-center"
>
<input <input
type="checkbox" type="checkbox"
class="checkbox checkbox-sm" class="checkbox checkbox-sm"
@ -289,7 +327,8 @@ defmodule MvWeb.GlobalSettingsLive do
<% end %> <% end %>
<.form for={@form} id="smtp-form" phx-change="validate" phx-submit="save"> <.form for={@form} id="smtp-form" phx-change="validate" phx-submit="save">
<div class="grid gap-4"> <div class="">
<div class="grid grid-cols-1 gap-4 lg:grid-cols-[2fr_5rem_1fr]">
<.input <.input
field={@form[:smtp_host]} field={@form[:smtp_host]}
type="text" type="text"
@ -309,6 +348,21 @@ defmodule MvWeb.GlobalSettingsLive do
disabled={@smtp_port_env_set} disabled={@smtp_port_env_set}
placeholder={if(@smtp_port_env_set, do: gettext("From SMTP_PORT"), else: "587")} placeholder={if(@smtp_port_env_set, do: gettext("From SMTP_PORT"), else: "587")}
/> />
<.input
field={@form[:smtp_ssl]}
type="select"
label={gettext("TLS/SSL")}
disabled={@smtp_ssl_env_set}
options={[
{gettext("TLS (port 587, recommended)"), "tls"},
{gettext("SSL (port 465)"), "ssl"},
{gettext("None (port 25, insecure)"), "none"}
]}
placeholder={if(@smtp_ssl_env_set, do: gettext("From SMTP_SSL"), else: nil)}
/>
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<.input <.input
field={@form[:smtp_username]} field={@form[:smtp_username]}
type="text" type="text"
@ -321,19 +375,10 @@ defmodule MvWeb.GlobalSettingsLive do
) )
} }
/> />
<div class="form-control">
<label class="label" for={@form[:smtp_password].id}>
<span class="label-text">{gettext("Password")}</span>
<%= if @smtp_password_set do %>
<span class="label-text-alt">
<.badge variant="neutral" size="sm">{gettext("(set)")}</.badge>
</span>
<% end %>
</label>
<.input <.input
field={@form[:smtp_password]} field={@form[:smtp_password]}
type="password" type="password"
label="" label={gettext("Password")}
disabled={@smtp_password_env_set} disabled={@smtp_password_env_set}
placeholder={ placeholder={
if(@smtp_password_env_set, if(@smtp_password_env_set,
@ -347,18 +392,8 @@ defmodule MvWeb.GlobalSettingsLive do
} }
/> />
</div> </div>
<.input
field={@form[:smtp_ssl]} <div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
type="select"
label={gettext("TLS/SSL")}
disabled={@smtp_ssl_env_set}
options={[
{gettext("TLS (port 587, recommended)"), "tls"},
{gettext("SSL (port 465)"), "ssl"},
{gettext("None (port 25, insecure)"), "none"}
]}
placeholder={if(@smtp_ssl_env_set, do: gettext("From SMTP_SSL"), else: nil)}
/>
<.input <.input
field={@form[:smtp_from_email]} field={@form[:smtp_from_email]}
type="email" type="email"
@ -381,7 +416,8 @@ defmodule MvWeb.GlobalSettingsLive do
} }
/> />
</div> </div>
<p class="mt-2 text-sm text-base-content/60"> </div>
<p class="mb-3 text-sm text-base-content/60">
{gettext( {gettext(
"The sender email must be owned by or authorized for the SMTP user on most servers." "The sender email must be owned by or authorized for the SMTP user on most servers."
)} )}
@ -411,24 +447,25 @@ defmodule MvWeb.GlobalSettingsLive do
class="space-y-3" class="space-y-3"
> >
<div class="flex flex-wrap items-end gap-3"> <div class="flex flex-wrap items-end gap-3">
<div class="form-control"> <fieldset class="fieldset">
<label class="label" for="smtp-test-to-email"> <label>
<span class="label-text">{gettext("Recipient")}</span> <span class="mb-1 label">{gettext("Recipient")}</span>
</label>
<input <input
id="smtp-test-to-email" id="smtp-test-to-email"
type="email" type="email"
name="to_email" name="to_email"
data-testid="smtp-test-email-input" data-testid="smtp-test-email-input"
value={@smtp_test_to_email} value={@smtp_test_to_email}
class="input input-bordered" class="w-full input input-bordered"
placeholder="test@example.com" placeholder="test@example.com"
phx-change="update_smtp_test_to_email" phx-change="update_smtp_test_to_email"
/> />
</div> </label>
</fieldset>
<.button <.button
type="submit" type="submit"
variant="outline" variant="secondary"
class="mb-1"
data-testid="smtp-send-test-email" data-testid="smtp-send-test-email"
phx-disable-with={gettext("Sending...")} phx-disable-with={gettext("Sending...")}
> >
@ -465,19 +502,27 @@ defmodule MvWeb.GlobalSettingsLive do
) )
} }
/> />
<div class="form-control"> <fieldset class="mb-2 fieldset">
<label class="label" for={@form[:vereinfacht_api_key].id}> <label>
<span class="label-text">{gettext("API Key")}</span> <span class="mb-1 label">{gettext("API Key")}</span>
<%= if @vereinfacht_api_key_set do %> <%= if @vereinfacht_api_key_set do %>
<span class="label-text-alt"> <span class="label-text-alt">
<.badge variant="neutral" size="sm">{gettext("(set)")}</.badge> <.badge variant="neutral" size="sm">{gettext("(set)")}</.badge>
</span> </span>
<% end %> <% end %>
</label> <input
<.input
field={@form[:vereinfacht_api_key]}
type="password" type="password"
label="" name={@form[:vereinfacht_api_key].name}
id={@form[:vereinfacht_api_key].id}
value={
Phoenix.HTML.Form.normalize_value("password", @form[:vereinfacht_api_key].value)
}
class={
if Phoenix.Component.used_input?(@form[:vereinfacht_api_key]) &&
@form[:vereinfacht_api_key].errors != [],
do: "w-full input input-error",
else: "w-full input"
}
disabled={@vereinfacht_api_key_env_set} disabled={@vereinfacht_api_key_env_set}
placeholder={ placeholder={
if(@vereinfacht_api_key_env_set, if(@vereinfacht_api_key_env_set,
@ -490,7 +535,20 @@ defmodule MvWeb.GlobalSettingsLive do
) )
} }
/> />
</div> </label>
<%= for msg <- (
if Phoenix.Component.used_input?(@form[:vereinfacht_api_key]) do
Enum.map(@form[:vereinfacht_api_key].errors, &MvWeb.CoreComponents.translate_error/1)
else
[]
end
) do %>
<p class="mt-1.5 flex gap-2 items-center text-sm text-error">
<.icon name="hero-exclamation-circle" class="size-5" />
{msg}
</p>
<% end %>
</fieldset>
<.input <.input
field={@form[:vereinfacht_club_id]} field={@form[:vereinfacht_club_id]}
type="text" type="text"
@ -528,7 +586,7 @@ defmodule MvWeb.GlobalSettingsLive do
<.button <.button
:if={Mv.Config.vereinfacht_configured?()} :if={Mv.Config.vereinfacht_configured?()}
type="button" type="button"
variant="outline" variant="secondary"
phx-click="test_vereinfacht_connection" phx-click="test_vereinfacht_connection"
phx-disable-with={gettext("Testing...")} phx-disable-with={gettext("Testing...")}
> >
@ -537,7 +595,7 @@ defmodule MvWeb.GlobalSettingsLive do
<.button <.button
:if={Mv.Config.vereinfacht_configured?()} :if={Mv.Config.vereinfacht_configured?()}
type="button" type="button"
variant="outline" variant="secondary"
phx-click="sync_vereinfacht_contacts" phx-click="sync_vereinfacht_contacts"
phx-disable-with={gettext("Syncing...")} phx-disable-with={gettext("Syncing...")}
> >
@ -552,8 +610,29 @@ defmodule MvWeb.GlobalSettingsLive do
<% end %> <% end %>
</.form> </.form>
</.form_section> </.form_section>
<%!-- OIDC Section --%> <%!-- Authentication: Direct registration + OIDC --%>
<.form_section title={gettext("OIDC (Single Sign-On)")}> <.form_section title={gettext("Authentication")}>
<h3 class="font-medium mb-3">{gettext("Direct registration")}</h3>
<p class="text-sm text-base-content/70 mb-4">
{gettext(
"If disabled, users cannot sign up via /register; sign-in and the join form remain available."
)}
</p>
<div class="flex items-center gap-3 mb-6">
<input
type="checkbox"
id="registration-enabled-checkbox"
class="checkbox checkbox-sm"
checked={@registration_enabled}
phx-click="toggle_registration_enabled"
aria-label={gettext("Allow direct registration (/register)")}
/>
<label for="registration-enabled-checkbox" class="cursor-pointer font-medium">
{gettext("Allow direct registration (/register)")}
</label>
</div>
<h3 class="font-medium mb-3">{gettext("OIDC (Single Sign-On)")}</h3>
<%= if @oidc_env_configured do %> <%= if @oidc_env_configured do %>
<p class="text-sm text-base-content/70 mb-4"> <p class="text-sm text-base-content/70 mb-4">
{gettext("Some values are set via environment variables. Those fields are read-only.")} {gettext("Some values are set via environment variables. Those fields are read-only.")}
@ -594,19 +673,27 @@ defmodule MvWeb.GlobalSettingsLive do
) )
} }
/> />
<div class="form-control"> <fieldset class="mb-2 fieldset">
<label class="label" for={@form[:oidc_client_secret].id}> <label>
<span class="label-text">{gettext("Client Secret")}</span> <span class="mb-1 label">{gettext("Client Secret")}</span>
<%= if @oidc_client_secret_set do %> <%= if @oidc_client_secret_set do %>
<span class="label-text-alt"> <span class="label-text-alt">
<.badge variant="neutral" size="sm">{gettext("(set)")}</.badge> <.badge variant="neutral" size="sm">{gettext("(set)")}</.badge>
</span> </span>
<% end %> <% end %>
</label> <input
<.input
field={@form[:oidc_client_secret]}
type="password" type="password"
label="" name={@form[:oidc_client_secret].name}
id={@form[:oidc_client_secret].id}
value={
Phoenix.HTML.Form.normalize_value("password", @form[:oidc_client_secret].value)
}
class={
if Phoenix.Component.used_input?(@form[:oidc_client_secret]) &&
@form[:oidc_client_secret].errors != [],
do: "w-full input input-error",
else: "w-full input"
}
disabled={@oidc_client_secret_env_set} disabled={@oidc_client_secret_env_set}
placeholder={ placeholder={
if(@oidc_client_secret_env_set, if(@oidc_client_secret_env_set,
@ -619,7 +706,20 @@ defmodule MvWeb.GlobalSettingsLive do
) )
} }
/> />
</div> </label>
<%= for msg <- (
if Phoenix.Component.used_input?(@form[:oidc_client_secret]) do
Enum.map(@form[:oidc_client_secret].errors, &MvWeb.CoreComponents.translate_error/1)
else
[]
end
) do %>
<p class="mt-1.5 flex gap-2 items-center text-sm text-error">
<.icon name="hero-exclamation-circle" class="size-5" />
{msg}
</p>
<% end %>
</fieldset>
<.input <.input
field={@form[:oidc_admin_group_name]} field={@form[:oidc_admin_group_name]}
type="text" type="text"
@ -681,6 +781,7 @@ defmodule MvWeb.GlobalSettingsLive do
</.button> </.button>
</.form> </.form>
</.form_section> </.form_section>
</div>
</Layouts.app> </Layouts.app>
""" """
end end
@ -776,6 +877,7 @@ defmodule MvWeb.GlobalSettingsLive do
socket = socket =
socket socket
|> assign(:settings, fresh_settings) |> assign(:settings, fresh_settings)
|> assign(:registration_enabled, fresh_settings.registration_enabled != false)
|> assign(:vereinfacht_api_key_set, present?(fresh_settings.vereinfacht_api_key)) |> assign(:vereinfacht_api_key_set, present?(fresh_settings.vereinfacht_api_key))
|> assign(:oidc_client_secret_set, Mv.Config.oidc_client_secret_set?()) |> assign(:oidc_client_secret_set, Mv.Config.oidc_client_secret_set?())
|> assign(:oidc_configured, Mv.Config.oidc_configured?()) |> assign(:oidc_configured, Mv.Config.oidc_configured?())
@ -796,12 +898,40 @@ defmodule MvWeb.GlobalSettingsLive do
# ---- Join form event handlers ---- # ---- Join form event handlers ----
@impl true
def handle_event("copy_join_url", _params, socket) do
socket =
socket
|> push_event("copy_to_clipboard", %{text: socket.assigns.join_url})
|> put_flash(:success, gettext("Join page URL copied to clipboard."))
{:noreply, socket}
end
@impl true @impl true
def handle_event("toggle_join_form_enabled", _params, socket) do def handle_event("toggle_join_form_enabled", _params, socket) do
socket = assign(socket, :join_form_enabled, not socket.assigns.join_form_enabled) socket = assign(socket, :join_form_enabled, not socket.assigns.join_form_enabled)
{:noreply, persist_join_form_settings(socket)} {:noreply, persist_join_form_settings(socket)}
end end
@impl true
def handle_event("toggle_registration_enabled", _params, socket) do
settings = socket.assigns.settings
new_value = not socket.assigns.registration_enabled
case Membership.update_settings(settings, %{registration_enabled: new_value}) do
{:ok, updated_settings} ->
{:noreply,
socket
|> assign(:settings, updated_settings)
|> assign(:registration_enabled, updated_settings.registration_enabled != false)
|> assign_form()}
{:error, _} ->
{:noreply, put_flash(socket, :error, gettext("Failed to update setting."))}
end
end
@impl true @impl true
def handle_event("toggle_add_field_dropdown", _params, socket) do def handle_event("toggle_add_field_dropdown", _params, socket) do
{:noreply, {:noreply,

View file

@ -32,7 +32,7 @@ defmodule MvWeb.GroupLive.Form do
socket socket
|> assign(:actor, actor) |> assign(:actor, actor)
|> assign(:group, nil) |> assign(:group, nil)
|> assign(:page_title, page_title_for_params(params)) |> Layouts.assign_page_title(page_title_for_params(params))
|> assign(:return_to, return_to_for_params(params))} |> assign(:return_to, return_to_for_params(params))}
else else
{:ok, redirect(socket, to: ~p"/groups")} {:ok, redirect(socket, to: ~p"/groups")}
@ -56,7 +56,7 @@ defmodule MvWeb.GroupLive.Form do
{:noreply, {:noreply,
socket socket
|> assign(:group, group) |> assign(:group, group)
|> assign(:page_title, gettext("Edit Group")) |> Layouts.assign_page_title(gettext("Edit Group"))
|> assign(:return_to, :show) |> assign(:return_to, :show)
|> assign_form(actor)} |> assign_form(actor)}
@ -85,7 +85,7 @@ defmodule MvWeb.GroupLive.Form do
{gettext("Back")} {gettext("Back")}
</.button> </.button>
</:leading> </:leading>
{@page_title} {@content_title}
<:actions> <:actions>
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit"> <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
{gettext("Save")} {gettext("Save")}

View file

@ -28,7 +28,7 @@ defmodule MvWeb.GroupLive.Index do
{:ok, {:ok,
socket socket
|> assign(:page_title, gettext("Groups")) |> Layouts.assign_page_title(gettext("Groups"))
|> assign(:groups, groups)} |> assign(:groups, groups)}
else else
{:ok, redirect(socket, to: ~p"/members")} {:ok, redirect(socket, to: ~p"/members")}
@ -40,7 +40,7 @@ defmodule MvWeb.GroupLive.Index do
~H""" ~H"""
<Layouts.app flash={@flash} current_user={@current_user}> <Layouts.app flash={@flash} current_user={@current_user}>
<.header> <.header>
{gettext("Groups")} {@content_title}
<:actions> <:actions>
<%= if can?(@current_user, :create, Mv.Membership.Group) do %> <%= if can?(@current_user, :create, Mv.Membership.Group) do %>
<.button navigate={~p"/groups/new"} variant="primary"> <.button navigate={~p"/groups/new"} variant="primary">

View file

@ -70,9 +70,11 @@ defmodule MvWeb.GroupLive.Show do
{:ok, group} -> {:ok, group} ->
open_delete = params["confirm_delete"] == "1" && can?(actor, :destroy, group) open_delete = params["confirm_delete"] == "1" && can?(actor, :destroy, group)
content_title = gettext("Group %{name}", name: group.name)
socket = socket =
socket socket
|> assign(:page_title, group.name) |> Layouts.assign_page_title(content_title)
|> assign(:group, group) |> assign(:group, group)
|> assign(:show_delete_modal, open_delete) |> assign(:show_delete_modal, open_delete)
|> assign(:name_confirmation, "") |> assign(:name_confirmation, "")
@ -102,7 +104,7 @@ defmodule MvWeb.GroupLive.Show do
{gettext("Back")} {gettext("Back")}
</.button> </.button>
</:leading> </:leading>
{@group.name} {@content_title}
<:actions> <:actions>
<%= if can?(@current_user, :update, @group) do %> <%= if can?(@current_user, :update, @group) do %>
<.button <.button

View file

@ -65,7 +65,7 @@ defmodule MvWeb.ImportLive do
socket = socket =
socket socket
|> assign(:page_title, gettext("Import")) |> Layouts.assign_page_title(gettext("Import"))
|> assign(:club_name, club_name) |> assign(:club_name, club_name)
|> assign(:import_state, nil) |> assign(:import_state, nil)
|> assign(:import_progress, nil) |> assign(:import_progress, nil)
@ -94,7 +94,7 @@ defmodule MvWeb.ImportLive do
<%!-- CSV Import Section --%> <%!-- CSV Import Section --%>
<div data-testid="import-page"> <div data-testid="import-page">
<.header> <.header>
{gettext("Import Members")} {@content_title}
<:subtitle> <:subtitle>
{gettext("Import members from CSV files.")} {gettext("Import members from CSV files.")}
</:subtitle> </:subtitle>

View file

@ -12,12 +12,22 @@ defmodule MvWeb.JoinLive do
# Honeypot field name (legitimate-sounding to avoid bot detection) # Honeypot field name (legitimate-sounding to avoid bot detection)
@honeypot_field "website" @honeypot_field "website"
# Anti-enumeration: delay before showing success (ms). Applied in LiveView so the process is not blocked.
@anti_enumeration_delay_ms_min 100
@anti_enumeration_delay_ms_rand 200
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
allowlist = Membership.get_join_form_allowlist() allowlist = Membership.get_join_form_allowlist()
join_fields = build_join_fields_with_labels(allowlist) join_fields = build_join_fields_with_labels(allowlist)
client_ip = client_ip_from_socket(socket) client_ip = client_ip_from_socket(socket)
club_name =
case Membership.get_settings() do
{:ok, s} -> s.club_name || "Mitgliederverwaltung"
_ -> "Mitgliederverwaltung"
end
socket = socket =
socket socket
|> assign(:join_fields, join_fields) |> assign(:join_fields, join_fields)
@ -25,6 +35,8 @@ defmodule MvWeb.JoinLive do
|> assign(:rate_limit_error, nil) |> assign(:rate_limit_error, nil)
|> assign(:client_ip, client_ip) |> assign(:client_ip, client_ip)
|> assign(:honeypot_field, @honeypot_field) |> assign(:honeypot_field, @honeypot_field)
|> assign(:club_name, club_name)
|> Layouts.assign_page_title(gettext("Join"))
|> assign(:form, to_form(initial_form_params(join_fields))) |> assign(:form, to_form(initial_form_params(join_fields)))
{:ok, socket} {:ok, socket}
@ -33,8 +45,11 @@ defmodule MvWeb.JoinLive do
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<Layouts.app flash={@flash} current_user={@current_user}> <Layouts.public_page flash={@flash} club_name={@club_name}>
<div class="max-w-xl mx-auto mt-8 space-y-6"> <div class="max-w-4xl mx-auto">
<div class="hero min-h-[60vh] bg-base-200 rounded-lg">
<div class="hero-content flex-col items-start text-left">
<div class="max-w-xl w-full space-y-6">
<.header> <.header>
{gettext("Become a member")} {gettext("Become a member")}
</.header> </.header>
@ -97,13 +112,13 @@ defmodule MvWeb.JoinLive do
/> />
</div> </div>
<p class="text-sm text-base-content/70"> <p class="text-sm text-base-content/85">
{gettext( {gettext(
"By submitting your application you will receive an email with a confirmation link. Once you have confirmed your email address, your application will be reviewed." "By submitting your application you will receive an email with a confirmation link. Once you have confirmed your email address, your application will be reviewed."
)} )}
</p> </p>
<p class="text-xs text-base-content/60"> <p class="text-xs text-base-content/80">
{gettext( {gettext(
"Your details are only used to process your membership application and to contact you. To prevent abuse we also process technical data (e.g. IP address) only as necessary." "Your details are only used to process your membership application and to contact you. To prevent abuse we also process technical data (e.g. IP address) only as necessary."
)} )}
@ -117,7 +132,10 @@ defmodule MvWeb.JoinLive do
</.form> </.form>
<% end %> <% end %>
</div> </div>
</Layouts.app> </div>
</div>
</div>
</Layouts.public_page>
""" """
end end
@ -142,8 +160,26 @@ defmodule MvWeb.JoinLive do
case build_submit_attrs(params, socket.assigns.join_fields) do case build_submit_attrs(params, socket.assigns.join_fields) do
{:ok, attrs} -> {:ok, attrs} ->
case Membership.submit_join_request(attrs, actor: nil) do case Membership.submit_join_request(attrs, actor: nil) do
{:ok, _} -> {:noreply, assign(socket, :submitted, true)} {:ok, _} ->
{:error, _} -> validation_error_reply(socket, params) delay_ms =
@anti_enumeration_delay_ms_min + :rand.uniform(@anti_enumeration_delay_ms_rand)
Process.send_after(self(), :show_join_success, delay_ms)
{:noreply, socket}
{:error, :email_delivery_failed} ->
{:noreply,
socket
|> put_flash(
:error,
gettext(
"We could not send the confirmation email. Please try again later or contact support."
)
)
|> assign(:form, to_form(params, as: "join"))}
{:error, _} ->
validation_error_reply(socket, params)
end end
{:error, message} -> {:error, message} ->
@ -161,6 +197,16 @@ defmodule MvWeb.JoinLive do
|> assign(:form, to_form(params, as: "join"))} |> assign(:form, to_form(params, as: "join"))}
end end
@impl true
def handle_info(:show_join_success, socket) do
{:noreply, assign(socket, :submitted, true)}
end
# Swoosh (e.g. in test) may send {:email, email} to the LiveView process; ignore.
def handle_info(_msg, socket) do
{:noreply, socket}
end
defp rate_limited_reply(socket, params) do defp rate_limited_reply(socket, params) do
{:noreply, {:noreply,
socket socket

View file

@ -21,9 +21,24 @@ defmodule MvWeb.JoinRequestLive.Helpers do
@doc """ @doc """
Returns the reviewer display string (e.g. email) for a join request, or nil if none. Returns the reviewer display string (e.g. email) for a join request, or nil if none.
Accepts a join request struct or map with optional :reviewed_by_user (loaded User struct). Prefers the denormalized :reviewed_by_display (set on approve/reject) so the UI
works for all roles without loading the User resource. Falls back to
:reviewed_by_user when loaded (e.g. admin or legacy data before backfill).
""" """
def reviewer_display(req) when is_map(req) do def reviewer_display(req) when is_map(req) do
case Map.get(req, :reviewed_by_display) do
s when is_binary(s) ->
trimmed = String.trim(s)
if trimmed == "", do: reviewer_display_from_user(req), else: trimmed
_ ->
reviewer_display_from_user(req)
end
end
def reviewer_display(_), do: nil
defp reviewer_display_from_user(req) do
user = Map.get(req, :reviewed_by_user) user = Map.get(req, :reviewed_by_user)
case user do case user do
@ -42,6 +57,4 @@ defmodule MvWeb.JoinRequestLive.Helpers do
nil nil
end end
end end
def reviewer_display(_), do: nil
end end

View file

@ -43,7 +43,7 @@ defmodule MvWeb.JoinRequestLive.Index do
~H""" ~H"""
<Layouts.app flash={@flash} current_user={@current_user}> <Layouts.app flash={@flash} current_user={@current_user}>
<.header> <.header>
{gettext("Join requests")} {@content_title}
</.header> </.header>
<div class="mt-6 space-y-8 max-w-4xl"> <div class="mt-6 space-y-8 max-w-4xl">
@ -63,7 +63,7 @@ defmodule MvWeb.JoinRequestLive.Index do
> >
<:col :let={req} label={gettext("Submitted at")}> <:col :let={req} label={gettext("Submitted at")}>
<%= if req.submitted_at do %> <%= if req.submitted_at do %>
{DateFormatter.format_datetime(req.submitted_at)} {DateFormatter.format_datetime(req.submitted_at, @browser_timezone)}
<% else %> <% else %>
<.empty_cell sr_text={gettext("Not submitted yet")} /> <.empty_cell sr_text={gettext("Not submitted yet")} />
<% end %> <% end %>
@ -125,7 +125,7 @@ defmodule MvWeb.JoinRequestLive.Index do
</.badge> </.badge>
</:col> </:col>
<:col :let={req} label={gettext("Reviewed at")}> <:col :let={req} label={gettext("Reviewed at")}>
{review_date(req)} {review_date(req, @browser_timezone)}
</:col> </:col>
<:col :let={req} label={gettext("Review by")}> <:col :let={req} label={gettext("Review by")}>
{JoinRequestHelpers.reviewer_display(req) || ""} {JoinRequestHelpers.reviewer_display(req) || ""}
@ -159,10 +159,10 @@ defmodule MvWeb.JoinRequestLive.Index do
assign(socket, :join_requests_history, []) assign(socket, :join_requests_history, [])
end end
assign(socket, :page_title, gettext("Join requests")) Layouts.assign_page_title(socket, gettext("Join requests"))
end end
defp review_date(req) do defp review_date(req, timezone) do
date = date =
case req.status do case req.status do
:approved -> req.approved_at :approved -> req.approved_at
@ -170,6 +170,6 @@ defmodule MvWeb.JoinRequestLive.Index do
_ -> nil _ -> nil
end end
if date, do: DateFormatter.format_datetime(date), else: "" if date, do: DateFormatter.format_datetime(date, timezone), else: ""
end end
end end

View file

@ -32,7 +32,7 @@ defmodule MvWeb.JoinRequestLive.Show do
socket socket
|> assign(:join_request, nil) |> assign(:join_request, nil)
|> assign(:join_form_field_ids, []) |> assign(:join_form_field_ids, [])
|> assign(:page_title, gettext("Join request"))} |> Layouts.assign_page_title(gettext("Join request"))}
else else
{:ok, redirect(socket, to: ~p"/members")} {:ok, redirect(socket, to: ~p"/members")}
end end
@ -57,7 +57,7 @@ defmodule MvWeb.JoinRequestLive.Show do
socket socket
|> assign(:join_request, request) |> assign(:join_request, request)
|> assign(:join_form_field_ids, field_ids) |> assign(:join_form_field_ids, field_ids)
|> assign(:page_title, gettext("Join request %{email}", email: request.email))} |> Layouts.assign_page_title(gettext("Join request %{email}", email: request.email))}
{:error, _error} -> {:error, _error} ->
{:noreply, {:noreply,
@ -123,28 +123,28 @@ defmodule MvWeb.JoinRequestLive.Show do
{gettext("Back")} {gettext("Back")}
</.button> </.button>
</:leading> </:leading>
{gettext("Join request")} {@content_title}
</.header> </.header>
<%= if @join_request do %> <%= if @join_request do %>
<div class="mt-6 space-y-6 max-w-2xl"> <div class="mt-6 space-y-6 max-w-2xl">
<%!-- Single block: all applicant-provided data in join form order --%>
<div> <div>
<h2 class="text-lg font-semibold mb-2">{gettext("Request data")}</h2> <h2 class="text-lg font-semibold mb-2">{gettext("Applicant data")}</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100 space-y-2">
<%= for {label, value} <- applicant_data_rows(@join_request, @join_form_field_ids || []) do %>
<.field_row label={label} value={value} empty_text={gettext("Not specified")} />
<% end %>
</div>
</div>
<%!-- Status and review (submitted_at, status; if decided: approved/rejected at, reviewed by) --%>
<div>
<h2 class="text-lg font-semibold mb-2">{gettext("Status and review")}</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100 space-y-2"> <div class="border border-base-300 rounded-lg p-4 bg-base-100 space-y-2">
<.field_row label={gettext("Email")} value={@join_request.email} />
<.field_row
label={gettext("First name")}
value={@join_request.first_name}
empty_text={gettext("Not specified")}
/>
<.field_row
label={gettext("Last name")}
value={@join_request.last_name}
empty_text={gettext("Not specified")}
/>
<.field_row <.field_row
label={gettext("Submitted at")} label={gettext("Submitted at")}
value={DateFormatter.format_datetime(@join_request.submitted_at)} value={DateFormatter.format_datetime(@join_request.submitted_at, @browser_timezone)}
/> />
<div class="flex gap-2"> <div class="flex gap-2">
<span class="text-base-content/60 min-w-32 shrink-0">{gettext("Status")}:</span> <span class="text-base-content/60 min-w-32 shrink-0">{gettext("Status")}:</span>
@ -154,34 +154,21 @@ defmodule MvWeb.JoinRequestLive.Show do
</.badge> </.badge>
</span> </span>
</div> </div>
</div>
</div>
<%= if map_size(@join_request.form_data || %{}) > 0 do %>
<div>
<h2 class="text-lg font-semibold mb-2">{gettext("Additional form data")}</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100 space-y-2">
<%= for {key, value} <- format_form_data(@join_request.form_data, @join_form_field_ids || []) do %>
<.field_row label={key} value={to_string(value)} />
<% end %>
</div>
</div>
<% end %>
<%= if @join_request.status in [:approved, :rejected] do %> <%= if @join_request.status in [:approved, :rejected] do %>
<div>
<h2 class="text-lg font-semibold mb-2">{gettext("Review information")}</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100 space-y-2">
<%= if @join_request.approved_at do %> <%= if @join_request.approved_at do %>
<.field_row <.field_row
label={gettext("Approved at")} label={gettext("Approved at")}
value={DateFormatter.format_datetime(@join_request.approved_at)} value={
DateFormatter.format_datetime(@join_request.approved_at, @browser_timezone)
}
/> />
<% end %> <% end %>
<%= if @join_request.rejected_at do %> <%= if @join_request.rejected_at do %>
<.field_row <.field_row
label={gettext("Rejected at")} label={gettext("Rejected at")}
value={DateFormatter.format_datetime(@join_request.rejected_at)} value={
DateFormatter.format_datetime(@join_request.rejected_at, @browser_timezone)
}
/> />
<% end %> <% end %>
<.field_row <.field_row
@ -189,9 +176,9 @@ defmodule MvWeb.JoinRequestLive.Show do
value={JoinRequestHelpers.reviewer_display(@join_request)} value={JoinRequestHelpers.reviewer_display(@join_request)}
empty_text="-" empty_text="-"
/> />
</div>
</div>
<% end %> <% end %>
</div>
</div>
<%= if @join_request.status == :submitted do %> <%= if @join_request.status == :submitted do %>
<div class="flex flex-wrap items-center justify-between gap-3 pt-2"> <div class="flex flex-wrap items-center justify-between gap-3 pt-2">
@ -240,40 +227,78 @@ defmodule MvWeb.JoinRequestLive.Show do
""" """
end end
# Formats form_data for display in join-form order; legacy keys (not in current # Builds a single list of {label, display_value} for all applicant-provided data in join form
# join_form_field_ids) are appended at the end, sorted by label for stability. # order. Typed fields (email, first_name, last_name) and form_data are merged; legacy
# Labels: member field keys → human-readable; UUID keys kept as-is (custom field IDs). # form_data keys (not in current join form config) are appended at the end.
defp format_form_data(nil, _ordered_field_ids), do: [] defp applicant_data_rows(join_request, ordered_field_ids) do
defp format_form_data(form_data, ordered_field_ids) when is_map(form_data) do
member_field_strings = Constants.member_fields() |> Enum.map(&Atom.to_string/1) member_field_strings = Constants.member_fields() |> Enum.map(&Atom.to_string/1)
form_data = join_request.form_data || %{}
typed = %{
"email" => join_request.email,
"first_name" => join_request.first_name,
"last_name" => join_request.last_name
}
# First: entries in current join form order (only keys present in form_data)
in_order = in_order =
ordered_field_ids ordered_field_ids
|> Enum.filter(&Map.has_key?(form_data, &1))
|> Enum.map(fn key -> |> Enum.map(fn key ->
value = form_data[key] value = Map.get(typed, key) || Map.get(form_data, key)
label = field_key_to_label(key, member_field_strings) label = field_key_to_label(key, member_field_strings)
{label, value} {label, format_applicant_value(value)}
end) end)
# Then: keys in form_data that are not in current settings (e.g. removed fields on old requests)
legacy_keys = legacy_keys =
form_data form_data
|> Map.keys() |> Map.keys()
|> Enum.reject(&(&1 in ordered_field_ids)) |> Enum.reject(fn k ->
k in ordered_field_ids or k in ["email", "first_name", "last_name"]
end)
|> Enum.sort() |> Enum.sort()
legacy_entries = legacy_entries =
Enum.map(legacy_keys, fn key -> Enum.map(legacy_keys, fn key ->
label = field_key_to_label(key, member_field_strings) label = field_key_to_label(key, member_field_strings)
{label, form_data[key]} {label, format_applicant_value(form_data[key])}
end) end)
in_order ++ legacy_entries in_order ++ legacy_entries
end end
defp format_applicant_value(nil), do: nil
defp format_applicant_value(""), do: nil
defp format_applicant_value(%Date{} = date), do: DateFormatter.format_date(date)
defp format_applicant_value(value) when is_map(value),
do: format_applicant_value_from_map(value)
defp format_applicant_value(value) when is_boolean(value),
do: if(value, do: gettext("Yes"), else: gettext("No"))
defp format_applicant_value(value) when is_binary(value) or is_number(value),
do: to_string(value)
defp format_applicant_value(value), do: to_string(value)
defp format_applicant_value_from_map(value) do
raw = Map.get(value, "_union_value") || Map.get(value, "value")
type = Map.get(value, "_union_type") || Map.get(value, "type")
if raw && type in ["date", :date] do
format_applicant_value(raw)
else
format_applicant_value_simple(raw, value)
end
end
defp format_applicant_value_simple(raw, _value) when is_binary(raw), do: raw
defp format_applicant_value_simple(raw, _value) when is_boolean(raw),
do: if(raw, do: gettext("Yes"), else: gettext("No"))
defp format_applicant_value_simple(raw, _value) when is_integer(raw), do: to_string(raw)
defp format_applicant_value_simple(_raw, value), do: to_string(value)
defp field_key_to_label(key, member_field_strings) when is_binary(key) do defp field_key_to_label(key, member_field_strings) when is_binary(key) do
if key in member_field_strings, if key in member_field_strings,
do: MemberFieldsTranslations.label(String.to_existing_atom(key)), do: MemberFieldsTranslations.label(String.to_existing_atom(key)),

View file

@ -374,7 +374,7 @@ defmodule MvWeb.MemberLive.Form do
id -> Ash.get!(MemberResource, id, load: [:membership_fee_type], actor: actor) id -> Ash.get!(MemberResource, id, load: [:membership_fee_type], actor: actor)
end end
page_title = content_title =
if is_nil(member), do: gettext("Create Member"), else: gettext("Edit Member") if is_nil(member), do: gettext("Create Member"), else: gettext("Edit Member")
# Load available membership fee types # Load available membership fee types
@ -389,7 +389,7 @@ defmodule MvWeb.MemberLive.Form do
|> assign(:custom_fields, custom_fields) |> assign(:custom_fields, custom_fields)
|> assign(:initial_custom_field_values, initial_custom_field_values) |> assign(:initial_custom_field_values, initial_custom_field_values)
|> assign(member: member) |> assign(member: member)
|> assign(:page_title, page_title) |> Layouts.assign_page_title(content_title)
|> assign(:available_fee_types, available_fee_types) |> assign(:available_fee_types, available_fee_types)
|> assign(:interval_warning, nil) |> assign(:interval_warning, nil)
|> assign(:member_field_required_map, member_field_required_map) |> assign(:member_field_required_map, member_field_required_map)

View file

@ -127,7 +127,7 @@ defmodule MvWeb.MemberLive.Index do
socket = socket =
socket socket
|> assign(:page_title, gettext("Members")) |> Layouts.assign_page_title(gettext("Members"))
|> assign(:query, "") |> assign(:query, "")
|> assign_new(:sort_field, fn -> :first_name end) |> assign_new(:sort_field, fn -> :first_name end)
|> assign_new(:sort_order, fn -> :asc end) |> assign_new(:sort_order, fn -> :asc end)

View file

@ -1,6 +1,6 @@
<Layouts.app flash={@flash} current_user={@current_user}> <Layouts.app flash={@flash} current_user={@current_user}>
<.header> <.header>
{gettext("Members")} {@content_title}
<:actions> <:actions>
<.live_component <.live_component
module={MvWeb.Components.ExportDropdown} module={MvWeb.Components.ExportDropdown}

View file

@ -47,7 +47,7 @@ defmodule MvWeb.MemberLive.Show do
{gettext("Back")} {gettext("Back")}
</.button> </.button>
</:leading> </:leading>
{MemberHelpers.display_name(@member)} {@content_title}
<:actions> <:actions>
<%= if can?(@current_user, :update, @member) do %> <%= if can?(@current_user, :update, @member) do %>
<.button <.button
@ -435,9 +435,12 @@ defmodule MvWeb.MemberLive.Show do
|> Map.put(:last_cycle_status, last_cycle_status) |> Map.put(:last_cycle_status, last_cycle_status)
|> Map.put(:current_cycle_status, current_cycle_status) |> Map.put(:current_cycle_status, current_cycle_status)
content_title =
gettext("Member %{name}", name: MemberHelpers.display_name(member))
{:noreply, {:noreply,
socket socket
|> assign(:page_title, page_title(socket.assigns.live_action)) |> Layouts.assign_page_title(content_title)
|> assign(:member, member)} |> assign(:member, member)}
end end
@ -565,9 +568,6 @@ defmodule MvWeb.MemberLive.Show do
{:noreply, assign(socket, :member, member)} {:noreply, assign(socket, :member, member)}
end end
defp page_title(:show), do: gettext("Show Member")
defp page_title(:edit), do: gettext("Edit Member")
defp format_error(%Ash.Error.Invalid{errors: errors}) do defp format_error(%Ash.Error.Invalid{errors: errors}) do
error_messages = error_messages =
Enum.map(errors, fn Enum.map(errors, fn

View file

@ -33,7 +33,7 @@ defmodule MvWeb.MembershipFeeSettingsLive do
{:ok, {:ok,
socket socket
|> assign(:page_title, gettext("Membership Fee Settings")) |> Layouts.assign_page_title(gettext("Membership fee settings"))
|> assign(:settings, settings) |> assign(:settings, settings)
|> assign(:membership_fee_types, membership_fee_types) |> assign(:membership_fee_types, membership_fee_types)
|> assign(:member_counts, member_counts) |> assign(:member_counts, member_counts)
@ -140,7 +140,7 @@ defmodule MvWeb.MembershipFeeSettingsLive do
~H""" ~H"""
<Layouts.app flash={@flash} current_user={@current_user}> <Layouts.app flash={@flash} current_user={@current_user}>
<.header> <.header>
{gettext("Membership Fee Settings")} {@content_title}
<:subtitle> <:subtitle>
{gettext("Configure fee types for membership fees.")} {gettext("Configure fee types for membership fees.")}
</:subtitle> </:subtitle>

View file

@ -33,7 +33,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
{gettext("Back")} {gettext("Back")}
</.button> </.button>
</:leading> </:leading>
{@page_title} {@content_title}
<:actions> <:actions>
<.button <.button
form="membership-fee-type-form" form="membership-fee-type-form"
@ -221,7 +221,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
id -> Ash.get!(MembershipFeeType, id, domain: MembershipFees, actor: actor) id -> Ash.get!(MembershipFeeType, id, domain: MembershipFees, actor: actor)
end end
page_title = content_title =
if is_nil(membership_fee_type), if is_nil(membership_fee_type),
do: gettext("New Membership Fee Type"), do: gettext("New Membership Fee Type"),
else: gettext("Edit Membership Fee Type") else: gettext("Edit Membership Fee Type")
@ -230,7 +230,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
socket socket
|> assign(:return_to, return_to(params["return_to"])) |> assign(:return_to, return_to(params["return_to"]))
|> assign(:membership_fee_type, membership_fee_type) |> assign(:membership_fee_type, membership_fee_type)
|> assign(:page_title, page_title) |> Layouts.assign_page_title(content_title)
|> assign(:show_amount_warning, false) |> assign(:show_amount_warning, false)
|> assign(:old_amount, nil) |> assign(:old_amount, nil)
|> assign(:new_amount, nil) |> assign(:new_amount, nil)

View file

@ -32,7 +32,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
{:ok, {:ok,
socket socket
|> assign(:page_title, gettext("Membership Fee Types")) |> Layouts.assign_page_title(gettext("Membership fee settings"))
|> assign(:membership_fee_types, fee_types) |> assign(:membership_fee_types, fee_types)
|> assign(:member_counts, member_counts)} |> assign(:member_counts, member_counts)}
end end
@ -42,7 +42,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
~H""" ~H"""
<Layouts.app flash={@flash} current_user={@current_user}> <Layouts.app flash={@flash} current_user={@current_user}>
<.header> <.header>
{gettext("Membership Fee Types")} {@content_title}
<:subtitle> <:subtitle>
{gettext("Manage membership fee types for membership fees.")} {gettext("Manage membership fee types for membership fees.")}
</:subtitle> </:subtitle>

View file

@ -29,7 +29,7 @@ defmodule MvWeb.RoleLive.Form do
{gettext("Back")} {gettext("Back")}
</.button> </.button>
</:leading> </:leading>
{@page_title} {@content_title}
<:actions> <:actions>
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit"> <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
{gettext("Save")} {gettext("Save")}
@ -94,14 +94,13 @@ defmodule MvWeb.RoleLive.Form do
def mount(params, _session, socket) do def mount(params, _session, socket) do
case params["id"] do case params["id"] do
nil -> nil ->
action = gettext("New") content_title = gettext("New") <> " " <> gettext("Role")
page_title = action <> " " <> gettext("Role")
{:ok, {:ok,
socket socket
|> assign(:return_to, return_to(params["return_to"])) |> assign(:return_to, return_to(params["return_to"]))
|> assign(:role, nil) |> assign(:role, nil)
|> assign(:page_title, page_title) |> Layouts.assign_page_title(content_title)
|> assign_form()} |> assign_form()}
id -> id ->
@ -113,14 +112,13 @@ defmodule MvWeb.RoleLive.Form do
actor: socket.assigns[:current_user] actor: socket.assigns[:current_user]
) do ) do
{:ok, role} -> {:ok, role} ->
action = gettext("Edit") content_title = gettext("Edit") <> " " <> gettext("Role")
page_title = action <> " " <> gettext("Role")
{:ok, {:ok,
socket socket
|> assign(:return_to, return_to(params["return_to"])) |> assign(:return_to, return_to(params["return_to"]))
|> assign(:role, role) |> assign(:role, role)
|> assign(:page_title, page_title) |> Layouts.assign_page_title(content_title)
|> assign_form()} |> assign_form()}
{:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{} | _]}} -> {:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{} | _]}} ->

View file

@ -28,7 +28,7 @@ defmodule MvWeb.RoleLive.Index do
{:ok, {:ok,
socket socket
|> assign(:page_title, gettext("Listing Roles")) |> Layouts.assign_page_title(gettext("Roles"))
|> assign(:roles, roles) |> assign(:roles, roles)
|> assign(:user_counts, user_counts)} |> assign(:user_counts, user_counts)}
end end

View file

@ -1,6 +1,6 @@
<Layouts.app flash={@flash} current_user={@current_user}> <Layouts.app flash={@flash} current_user={@current_user}>
<.header> <.header>
{gettext("Listing Roles")} {@content_title}
<:subtitle> <:subtitle>
{gettext("Manage roles and their permission sets.")} {gettext("Manage roles and their permission sets.")}
</:subtitle> </:subtitle>

View file

@ -30,9 +30,11 @@ defmodule MvWeb.RoleLive.Show do
{:ok, role} -> {:ok, role} ->
user_count = load_user_count(role, socket.assigns[:current_user]) user_count = load_user_count(role, socket.assigns[:current_user])
content_title = gettext("Role %{name}", name: role.name)
{:ok, {:ok,
socket socket
|> assign(:page_title, gettext("Show Role")) |> Layouts.assign_page_title(content_title)
|> assign(:role, role) |> assign(:role, role)
|> assign(:user_count, user_count) |> assign(:user_count, user_count)
|> assign(:show_delete_modal, false)} |> assign(:show_delete_modal, false)}
@ -202,7 +204,7 @@ defmodule MvWeb.RoleLive.Show do
{gettext("Back")} {gettext("Back")}
</.button> </.button>
</:leading> </:leading>
{gettext("Role")} {@role.name} {@content_title}
<:subtitle>{gettext("Role details and permissions.")}</:subtitle> <:subtitle>{gettext("Role details and permissions.")}</:subtitle>
<:actions> <:actions>

View file

@ -18,7 +18,7 @@ defmodule MvWeb.StatisticsLive do
# Only static assigns and fee types here; load_statistics runs once in handle_params # Only static assigns and fee types here; load_statistics runs once in handle_params
socket = socket =
socket socket
|> assign(:page_title, gettext("Statistics")) |> Layouts.assign_page_title(gettext("Statistics"))
|> assign(:selected_fee_type_id, nil) |> assign(:selected_fee_type_id, nil)
|> load_fee_types() |> load_fee_types()
@ -58,7 +58,7 @@ defmodule MvWeb.StatisticsLive do
~H""" ~H"""
<Layouts.app flash={@flash} current_user={@current_user}> <Layouts.app flash={@flash} current_user={@current_user}>
<.header> <.header>
{gettext("Statistics")} {@content_title}
</.header> </.header>
<section class="mb-8" aria-labelledby="members-heading"> <section class="mb-8" aria-labelledby="members-heading">

View file

@ -59,7 +59,7 @@ defmodule MvWeb.UserLive.Form do
{gettext("Back")} {gettext("Back")}
</.button> </.button>
</:leading> </:leading>
{@page_title} {@content_title}
<:actions> <:actions>
<.button <.button
form="user-form" form="user-form"
@ -423,8 +423,9 @@ defmodule MvWeb.UserLive.Form do
defp mount_continue(user, params, socket) do defp mount_continue(user, params, socket) do
actor = current_actor(socket) actor = current_actor(socket)
action = if is_nil(user), do: gettext("New"), else: gettext("Edit")
page_title = action <> " " <> gettext("User") content_title =
if(is_nil(user), do: gettext("New"), else: gettext("Edit")) <> " " <> gettext("User")
# Only admins can link/unlink users to members (permission docs; prevents privilege escalation). # Only admins can link/unlink users to members (permission docs; prevents privilege escalation).
can_manage_member_linking = can?(actor, :destroy, UserResource) can_manage_member_linking = can?(actor, :destroy, UserResource)
@ -436,7 +437,7 @@ defmodule MvWeb.UserLive.Form do
socket socket
|> assign(:return_to, return_to(params["return_to"])) |> assign(:return_to, return_to(params["return_to"]))
|> assign(user: user) |> assign(user: user)
|> assign(:page_title, page_title) |> Layouts.assign_page_title(content_title)
|> assign(:can_manage_member_linking, can_manage_member_linking) |> assign(:can_manage_member_linking, can_manage_member_linking)
|> assign(:can_assign_role, can_assign_role) |> assign(:can_assign_role, can_assign_role)
|> assign(:roles, roles) |> assign(:roles, roles)

View file

@ -38,7 +38,7 @@ defmodule MvWeb.UserLive.Index do
{:ok, {:ok,
socket socket
|> assign(:page_title, gettext("Listing Users")) |> Layouts.assign_page_title(gettext("Users"))
|> assign(:sort_field, :email) |> assign(:sort_field, :email)
|> assign(:sort_order, :asc) |> assign(:sort_order, :asc)
|> assign(:users, sorted)} |> assign(:users, sorted)}

View file

@ -1,6 +1,6 @@
<Layouts.app flash={@flash} current_user={@current_user}> <Layouts.app flash={@flash} current_user={@current_user}>
<.header> <.header>
{gettext("Users")} {@content_title}
<:subtitle>{gettext("Manage users and their permissions.")}</:subtitle> <:subtitle>{gettext("Manage users and their permissions.")}</:subtitle>
<:actions> <:actions>
<%= if can?(@current_user, :create, Mv.Accounts.User) do %> <%= if can?(@current_user, :create, Mv.Accounts.User) do %>

View file

@ -48,7 +48,7 @@ defmodule MvWeb.UserLive.Show do
{gettext("Back")} {gettext("Back")}
</.button> </.button>
</:leading> </:leading>
{gettext("User")} {@user.email} {@content_title}
<:actions> <:actions>
<%= if can?(@current_user, :update, @user) do %> <%= if can?(@current_user, :update, @user) do %>
<.button <.button
@ -179,9 +179,11 @@ defmodule MvWeb.UserLive.Show do
|> put_flash(:error, gettext("This user cannot be viewed.")) |> put_flash(:error, gettext("This user cannot be viewed."))
|> push_navigate(to: ~p"/users")} |> push_navigate(to: ~p"/users")}
else else
content_title = gettext("User %{email}", email: user.email)
{:ok, {:ok,
socket socket
|> assign(:page_title, gettext("Show User")) |> Layouts.assign_page_title(content_title)
|> assign(:user, user) |> assign(:user, user)
|> assign(:show_delete_modal, false)} |> assign(:show_delete_modal, false)}
end end

View file

@ -17,11 +17,29 @@ defmodule MvWeb.LiveHelpers do
""" """
import Phoenix.Component import Phoenix.Component
alias Mv.Authorization.Actor alias Mv.Authorization.Actor
alias Mv.Membership
alias MvWeb.Plugs.CheckPagePermission alias MvWeb.Plugs.CheckPagePermission
def on_mount(:default, _params, session, socket) do def on_mount(:default, _params, session, socket) do
locale = session["locale"] || "de" locale = session["locale"] || "de"
Gettext.put_locale(locale) Gettext.put_locale(locale)
# Browser timezone from LiveSocket connect params (set in app.js via Intl API)
connect_params = socket.private[:connect_params] || %{}
timezone = connect_params["timezone"] || connect_params[:timezone]
# Club name for browser tab title (Mila · Club · Page)
club_name =
case Membership.get_settings() do
{:ok, settings} when is_binary(settings.club_name) -> settings.club_name
_ -> nil
end
socket =
socket
|> assign(:browser_timezone, timezone)
|> assign(:club_name, club_name)
{:cont, socket} {:cont, socket}
end end

View file

@ -0,0 +1,22 @@
defmodule MvWeb.Plugs.AssignClubName do
@moduledoc """
Assigns :club_name from settings for controller-rendered pages.
Used by the root layout to build the browser tab title (Mila · Club · Page).
LiveViews set club_name in on_mount instead.
"""
import Plug.Conn
alias Mv.Membership
def init(opts), do: opts
def call(conn, _opts) do
club_name =
case Membership.get_settings() do
{:ok, settings} when is_binary(settings.club_name) -> settings.club_name
_ -> nil
end
assign(conn, :club_name, club_name)
end
end

View file

@ -0,0 +1,55 @@
defmodule MvWeb.Plugs.RegistrationEnabled do
@moduledoc """
When direct registration is disabled in settings:
- GET /register is redirected to /sign-in with a flash message.
Puts registration_enabled from settings into session for /sign-in and /register
so the sign-in LiveView can show or hide the register link.
"""
import Plug.Conn
import Phoenix.Controller
alias Mv.Membership
def init(opts), do: opts
def call(conn, _opts) do
conn
|> maybe_redirect_register()
|> maybe_put_registration_enabled_in_session()
end
defp maybe_redirect_register(conn) do
if conn.request_path == "/register" and conn.method == "GET" do
case Membership.get_settings() do
{:ok, %{registration_enabled: true}} ->
conn
_ ->
conn
|> put_flash(:info, get_flash_message(conn))
|> redirect(to: "/sign-in")
|> halt()
end
else
conn
end
end
defp get_flash_message(_conn) do
Gettext.dgettext(MvWeb.Gettext, "default", "Registration is disabled.")
end
defp maybe_put_registration_enabled_in_session(conn) do
if conn.request_path in ["/sign-in", "/register"] do
enabled =
case Membership.get_settings() do
{:ok, %{registration_enabled: enabled?}} -> enabled?
_ -> true
end
put_session(conn, "registration_enabled", enabled)
else
conn
end
end
end

View file

@ -14,8 +14,10 @@ defmodule MvWeb.Router do
plug :put_secure_browser_headers plug :put_secure_browser_headers
plug :load_from_session plug :load_from_session
plug :set_locale plug :set_locale
plug MvWeb.Plugs.AssignClubName
plug MvWeb.Plugs.CheckPagePermission plug MvWeb.Plugs.CheckPagePermission
plug MvWeb.Plugs.JoinFormEnabled plug MvWeb.Plugs.JoinFormEnabled
plug MvWeb.Plugs.RegistrationEnabled
end end
pipeline :api do pipeline :api do

View file

@ -0,0 +1,10 @@
<div style="color: #111827;">
<p style="margin: 0 0 16px; font-size: 16px; line-height: 1.5;">
{gettext(
"We have received your request. The email address you entered is already registered as a member."
)}
</p>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
{gettext("If you have any questions, please contact us.")}
</p>
</div>

View file

@ -0,0 +1,10 @@
<div style="color: #111827;">
<p style="margin: 0 0 16px; font-size: 16px; line-height: 1.5;">
{gettext(
"We have received your request. You already have a membership application that is being reviewed."
)}
</p>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
{gettext("If you have any questions, please contact us.")}
</p>
</div>

View file

@ -1,4 +1,9 @@
<div style="color: #111827;"> <div style="color: #111827;">
<%= if @resend do %>
<p style="margin: 0 0 16px; font-size: 16px; line-height: 1.5;">
{gettext("You already had a pending request. Here is a new confirmation link.")}
</p>
<% end %>
<p style="margin: 0 0 16px; font-size: 16px; line-height: 1.5;"> <p style="margin: 0 0 16px; font-size: 16px; line-height: 1.5;">
{gettext( {gettext(
"We have received your membership request. To complete it, please click the link below." "We have received your membership request. To complete it, please click the link below."

View file

@ -85,7 +85,8 @@ defmodule Mv.MixProject do
{:slugify, "~> 1.3"}, {:slugify, "~> 1.3"},
{:nimble_csv, "~> 1.0"}, {:nimble_csv, "~> 1.0"},
{:imprintor, "~> 0.5.0"}, {:imprintor, "~> 0.5.0"},
{:hammer, "~> 7.0"} {:hammer, "~> 7.0"},
{:tz, "~> 0.28"}
] ]
end end

View file

@ -96,6 +96,7 @@
"text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"},
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
"tidewave": {:hex, :tidewave, "0.5.5", "a125dfc87f99daf0e2280b3a9719b874c616ead5926cdf9cdfe4fcc19a020eff", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "825ebb4fa20de005785efa21e5a88c04d81c3f57552638d12ff3def2f203dbf7"}, "tidewave": {:hex, :tidewave, "0.5.5", "a125dfc87f99daf0e2280b3a9719b874c616ead5926cdf9cdfe4fcc19a020eff", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "825ebb4fa20de005785efa21e5a88c04d81c3f57552638d12ff3def2f203dbf7"},
"tz": {:hex, :tz, "0.28.1", "717f5ffddfd1e475e2a233e221dc0b4b76c35c4b3650b060c8e3ba29dd6632e9", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:mint, "~> 1.6", [hex: :mint, repo: "hexpm", optional: true]}], "hexpm", "bfdca1aa1902643c6c43b77c1fb0cb3d744fd2f09a8a98405468afdee0848c8a"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
"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"},

View file

@ -139,18 +139,16 @@ msgid "This OIDC account is already linked to another user. Please contact suppo
msgstr "" msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex #: lib/mv_web/live/auth/link_oidc_account_live.ex
#: lib/mv_web/live/auth/sign_in_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Language selection" msgid "Language selection"
msgstr "" msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex #: lib/mv_web/live/auth/link_oidc_account_live.ex
#: lib/mv_web/live/auth/sign_in_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Select language" msgid "Select language"
msgstr "" msgstr ""
#: lib/mv_web/auth_overrides.ex #: lib/mv_web/live/auth/sign_in_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "or" msgid "Register"
msgstr "" msgstr ""

View file

@ -135,18 +135,16 @@ msgid "This OIDC account is already linked to another user. Please contact suppo
msgstr "Dieses OIDC-Konto ist bereits mit einer*m anderen Benutzer*in verknüpft. Bitte kontaktiere den Support." msgstr "Dieses OIDC-Konto ist bereits mit einer*m anderen Benutzer*in verknüpft. Bitte kontaktiere den Support."
#: lib/mv_web/live/auth/link_oidc_account_live.ex #: lib/mv_web/live/auth/link_oidc_account_live.ex
#: lib/mv_web/live/auth/sign_in_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Language selection" msgid "Language selection"
msgstr "Sprachauswahl" msgstr "Sprachauswahl"
#: lib/mv_web/live/auth/link_oidc_account_live.ex #: lib/mv_web/live/auth/link_oidc_account_live.ex
#: lib/mv_web/live/auth/sign_in_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Select language" msgid "Select language"
msgstr "Sprache auswählen" msgstr "Sprache auswählen"
#: lib/mv_web/auth_overrides.ex #: lib/mv_web/live/auth/sign_in_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "or" msgid "Register"
msgstr "oder" msgstr "Registrieren"

View file

@ -110,11 +110,6 @@ msgstr "Feld hinzufügen"
msgid "Add members" msgid "Add members"
msgstr "Mitglieder hinzufügen" msgstr "Mitglieder hinzufügen"
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Additional form data"
msgstr "Weitere Formulardaten"
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Address" msgid "Address"
@ -356,6 +351,7 @@ msgid "Base URL"
msgstr "Basis-URL" msgstr "Basis-URL"
#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Basic settings" msgid "Basic settings"
msgstr "Grundeinstellungen" msgstr "Grundeinstellungen"
@ -1084,7 +1080,6 @@ msgid "Edit Group"
msgstr "Gruppe bearbeiten" msgstr "Gruppe bearbeiten"
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Edit Member" msgid "Edit Member"
msgstr "Mitglied bearbeiten" msgstr "Mitglied bearbeiten"
@ -1121,7 +1116,6 @@ msgstr "Rolle bearbeiten"
#: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/join_request_live/index.ex #: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
@ -1374,7 +1368,6 @@ msgid "First Name"
msgstr "Vorname" msgstr "Vorname"
#: lib/mv_web/live/join_request_live/index.ex #: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "First name" msgid "First name"
@ -1563,17 +1556,17 @@ msgstr "Hausnummer"
#: lib/mv_web/templates/emails/user_confirmation.html.heex #: lib/mv_web/templates/emails/user_confirmation.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "If you did not create an account, you can ignore this email." 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." msgstr "Wenn du kein Konto angelegt hast, kannst du diese E-Mail ignorieren."
#: lib/mv_web/templates/emails/password_reset.html.heex #: lib/mv_web/templates/emails/password_reset.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "If you did not request this, you can ignore this email. Your password will remain unchanged." 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." msgstr "Wenn du das nicht angefordert hast, kannst du diese E-Mail ignorieren. Dein Passwort bleibt unverändert."
#: lib/mv_web/templates/emails/join_confirmation.html.heex #: lib/mv_web/templates/emails/join_confirmation.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "If you did not submit this request, you can ignore this email." 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." msgstr "Wenn du diese Anfrage nicht gestellt hast, kannst du diese E-Mail ignorieren."
#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/import_live.ex #: lib/mv_web/live/import_live.ex
@ -1581,11 +1574,6 @@ msgstr "Wenn Sie diese Anfrage nicht gestellt haben, können Sie diese E-Mail ig
msgid "Import" msgid "Import"
msgstr "Import" msgstr "Import"
#: lib/mv_web/live/import_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Import Members"
msgstr "Mitglieder importieren (CSV)"
#: lib/mv_web/live/import_live/components.ex #: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Import Results" msgid "Import Results"
@ -1695,7 +1683,7 @@ msgstr "Ungültiges Datumsformat"
msgid "Invalid email address. Please enter a valid recipient address." msgid "Invalid email address. Please enter a valid recipient address."
msgstr "Ungültige E-Mail-Adresse. Bitte gib eine gültige Empfängeradresse ein." msgstr "Ungültige E-Mail-Adresse. Bitte gib eine gültige Empfängeradresse ein."
#: lib/mv_web/controllers/join_confirm_controller.ex #: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Invalid or expired link." msgid "Invalid or expired link."
msgstr "Ungültiger oder abgelaufener Link." msgstr "Ungültiger oder abgelaufener Link."
@ -1792,7 +1780,6 @@ msgid "Last Name"
msgstr "Nachname" msgstr "Nachname"
#: lib/mv_web/live/join_request_live/index.ex #: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Last name" msgid "Last name"
@ -1836,17 +1823,6 @@ msgstr "Verknüpftes Mitglied"
msgid "Linked User" msgid "Linked User"
msgstr "Verknüpfte*r Benutzer*in" msgstr "Verknüpfte*r Benutzer*in"
#: lib/mv_web/live/role_live/index.ex
#: lib/mv_web/live/role_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Listing Roles"
msgstr "Rollen auflisten"
#: lib/mv_web/live/user_live/index.ex
#, elixir-autogen, elixir-format
msgid "Listing Users"
msgstr "Benutzer*innen auflisten"
#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Logout" msgid "Logout"
@ -1999,7 +1975,6 @@ msgstr "Die Verknüpfung des Mitglieds wird beim Speichern aufgehoben. Ein neues
#: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_live/index.ex #: lib/mv_web/live/member_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/membership_fee_type_live/index.ex
#: lib/mv_web/live/statistics_live.ex #: lib/mv_web/live/statistics_live.ex
@ -2018,11 +1993,6 @@ msgstr "Mitgliedertabelle"
msgid "Membership Fee" msgid "Membership Fee"
msgstr "Mitgliedsbeitrag" msgstr "Mitgliedsbeitrag"
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Membership Fee Settings"
msgstr "Mitgliedsbeitragseinstellungen"
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/translations/member_fields.ex #: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
@ -2042,7 +2012,6 @@ msgid "Membership Fee Type"
msgstr "Mitgliedsbeitragsart" msgstr "Mitgliedsbeitragsart"
#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Membership Fee Types" msgid "Membership Fee Types"
msgstr "Mitgliedsbeitragsarten" msgstr "Mitgliedsbeitragsarten"
@ -2054,6 +2023,8 @@ msgid "Membership Fees"
msgstr "Mitgliedsbeiträge" msgstr "Mitgliedsbeiträge"
#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Membership fee settings" msgid "Membership fee settings"
msgstr "Beitragseinstellungen" msgstr "Beitragseinstellungen"
@ -2178,6 +2149,7 @@ msgstr "Neuer Betrag"
#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_field_live/index_component.ex #: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/member_live/index/formatter.ex #: lib/mv_web/live/member_live/index/formatter.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
@ -2549,7 +2521,7 @@ msgstr "Bitte bestätige zuerst die Betragsänderung"
#: lib/mv_web/templates/emails/user_confirmation.html.heex #: lib/mv_web/templates/emails/user_confirmation.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Please confirm your email address by clicking the link below." 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." msgstr "Bitte bestätige deine E-Mail-Adresse, indem du auf den folgenden Link klickst."
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -2681,11 +2653,6 @@ msgstr "Mitglied aus Gruppe entfernen"
msgid "Reorder" msgid "Reorder"
msgstr "Umordnen" msgstr "Umordnen"
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Request data"
msgstr "Antragsdaten"
#: lib/mv_web/live/custom_field_live/form_component.ex #: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/global_settings_live.ex
@ -2711,18 +2678,12 @@ msgstr "Passwort zurücksetzen"
msgid "Review by" msgid "Review by"
msgstr "Geprüft von" msgstr "Geprüft von"
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Review information"
msgstr "Bearbeitungsinformationen"
#: lib/mv_web/live/join_request_live/index.ex #: lib/mv_web/live/join_request_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Reviewed at" msgid "Reviewed at"
msgstr "Geprüft am" msgstr "Geprüft am"
#: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/role_live/form.ex
#: lib/mv_web/live/role_live/show.ex
#: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/index.html.heex #: lib/mv_web/live/user_live/index.html.heex
#: lib/mv_web/live/user_live/show.ex #: lib/mv_web/live/user_live/show.ex
@ -2752,6 +2713,7 @@ msgid "Role saved successfully."
msgstr "Rolle erfolgreich gespeichert." msgstr "Rolle erfolgreich gespeichert."
#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/role_live/index.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Roles" msgid "Roles"
msgstr "Rollen" msgstr "Rollen"
@ -2988,11 +2950,6 @@ msgstr "Passwort setzen"
msgid "Sets whether the payment status filter and the membership fee status column use the last completed or the current payment cycle." msgid "Sets whether the payment status filter and the membership fee status column use the last completed or the current payment cycle."
msgstr "Legt fest, ob Bezahlstatusfilter und Mitgliedsbeitragsstatus-Spalte den letzten abgeschlossenen oder den aktuellen Zahlungszyklus verwenden." msgstr "Legt fest, ob Bezahlstatusfilter und Mitgliedsbeitragsstatus-Spalte den letzten abgeschlossenen oder den aktuellen Zahlungszyklus verwenden."
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Settings"
msgstr "Einstellungen"
#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Settings saved successfully." msgid "Settings saved successfully."
@ -3008,21 +2965,6 @@ msgstr "Einstellungen erfolgreich gespeichert"
msgid "Show" msgid "Show"
msgstr "Anzeigen" msgstr "Anzeigen"
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Show Member"
msgstr "Mitglied anzeigen"
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format
msgid "Show Role"
msgstr "Rolle anzeigen"
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format
msgid "Show User"
msgstr "Benutzer*in anzeigen"
#: 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 "Show bookings/receipts from Vereinfacht" msgid "Show bookings/receipts from Vereinfacht"
@ -3214,10 +3156,10 @@ msgstr "Wird getestet..."
msgid "Text" msgid "Text"
msgstr "Textfeld" msgstr "Textfeld"
#: lib/mv_web/controllers/join_confirm_controller.ex #: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Thank you, we have received your request." msgid "Thank you, we have received your request."
msgstr "Vielen Dank, wir haben Ihre Anfrage erhalten." msgstr "Vielen Dank, wir haben deine Anfrage erhalten."
#: lib/mv_web/controllers/auth_controller.ex #: lib/mv_web/controllers/auth_controller.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -3287,10 +3229,10 @@ msgstr "Dies ist ein technisches Feld und kann nicht verändert werden."
msgid "This is a test email sent from Mila. If you received this, your SMTP configuration is working correctly." msgid "This is a test email sent from Mila. If you received this, your SMTP configuration is working correctly."
msgstr "Dies ist eine Test-E-Mail von Mila. Wenn du diese erhalten hast, funktioniert deine SMTP-Konfiguration korrekt." msgstr "Dies ist eine Test-E-Mail von Mila. Wenn du diese erhalten hast, funktioniert deine SMTP-Konfiguration korrekt."
#: lib/mv_web/controllers/join_confirm_controller.ex #: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "This link has expired. Please submit the form again." msgid "This link has expired. Please submit the form again."
msgstr "Dieser Link ist abgelaufen. Bitte senden Sie das Formular erneut ab." msgstr "Dieser Link ist abgelaufen. Bitte sende das Formular erneut ab."
#: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -3322,7 +3264,7 @@ msgstr "Um die Löschung zu bestätigen, gib bitte den Gruppennamen ein:"
msgid "To confirm deletion, please enter this text:" msgid "To confirm deletion, please enter this text:"
msgstr "Um die Löschung zu bestätigen, gib bitte folgenden Text ein:" msgstr "Um die Löschung zu bestätigen, gib bitte folgenden Text ein:"
#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/components/core_components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Toggle dark mode" msgid "Toggle dark mode"
msgstr "Dunklen Modus umschalten" msgstr "Dunklen Modus umschalten"
@ -3407,7 +3349,6 @@ msgid "Use the data field name as the CSV column header in your file. Data field
msgstr "Verwende die Namen der Datenfelder als Spaltennamen in der CSV-Datei. Datenfelder müssen in Mila bereits angelegt sein, da unbekannte Spaltennamen ignoriert werden. Gruppen und Beitragsstatus können nicht importiert werden." msgstr "Verwende die Namen der Datenfelder als Spaltennamen in der CSV-Datei. Datenfelder müssen in Mila bereits angelegt sein, da unbekannte Spaltennamen ignoriert werden. Gruppen und Beitragsstatus können nicht importiert werden."
#: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "User" msgid "User"
msgstr "Benutzer*in" msgstr "Benutzer*in"
@ -3446,7 +3387,7 @@ msgstr "Benutzername"
#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/index.html.heex
#: lib/mv_web/live/user_live/index.html.heex #: lib/mv_web/live/user_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Users" msgid "Users"
msgstr "Benutzer*innen" msgstr "Benutzer*innen"
@ -3534,7 +3475,7 @@ msgstr "Keine Internetverbindung gefunden"
#: lib/mv_web/templates/emails/join_confirmation.html.heex #: lib/mv_web/templates/emails/join_confirmation.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "We have received your membership request. To complete it, please click the link below." 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." msgstr "Wir haben deine Mitgliedschaftsanfrage erhalten. Bitte klicke zur Bestätigung auf den folgenden Link."
#: lib/mv_web/live/join_live.ex #: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -3575,6 +3516,7 @@ msgstr "Jährliches Intervall Beitrittszeitraum einbezogen"
#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_field_live/index_component.ex #: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/member_live/index/formatter.ex #: lib/mv_web/live/member_live/index/formatter.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
@ -3651,7 +3593,7 @@ msgstr "Du hast dich bereits auf andere Weise angemeldet, aber dein Konto noch n
#: lib/mv_web/templates/emails/password_reset.html.heex #: lib/mv_web/templates/emails/password_reset.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "You requested a password reset. Click the link below to set a new password." 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." msgstr "Du hast die Zurücksetzung deines Passworts angefordert. Klicke auf den folgenden Link, um ein neues Passwort zu setzen."
#: lib/mv_web/live/join_live.ex #: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -3776,3 +3718,180 @@ msgstr "aktualisiert"
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "without %{name}" msgid "without %{name}"
msgstr "ohne %{name}" msgstr "ohne %{name}"
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Applicant data"
msgstr "Angaben des Antragstellers"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Copy"
msgstr "Kopieren"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Copy join page URL"
msgstr "URL der Beitrittsseite kopieren"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Join page URL"
msgstr "URL der Beitrittsseite"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Join page URL copied to clipboard."
msgstr "URL der Beitrittsseite in die Zwischenablage kopiert."
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Link to the public join page (share this with applicants):"
msgstr "Link zur öffentlichen Beitrittsseite (diesen Link mit Interessent*innen teilen):"
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Status and review"
msgstr "Status und Prüfung"
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "We could not send the confirmation email. Please try again later or contact support."
msgstr "Die Bestätigungs-E-Mail konnte nicht versendet werden. Bitte versuche es später erneut oder wende dich an den Support."
#: lib/mv_web/templates/emails/join_already_member.html.heex
#: lib/mv_web/templates/emails/join_already_pending.html.heex
#, elixir-autogen, elixir-format
msgid "If you have any questions, please contact us."
msgstr "Bei Fragen kannst du dich gerne an uns wenden."
#: lib/mv_web/emails/join_already_member_email.ex
#, elixir-autogen, elixir-format
msgid "Membership application already a member"
msgstr "Mitgliedsantrag bereits Mitglied"
#: lib/mv_web/emails/join_already_pending_email.ex
#, elixir-autogen, elixir-format
msgid "Membership application already under review"
msgstr "Mitgliedsantrag wird bereits geprüft"
#: lib/mv_web/templates/emails/join_already_member.html.heex
#, elixir-autogen, elixir-format
msgid "We have received your request. The email address you entered is already registered as a member."
msgstr "Wir haben deine Anfrage erhalten. Die angegebene E-Mail-Adresse ist bereits als Mitglied registriert."
#: lib/mv_web/templates/emails/join_already_pending.html.heex
#, elixir-autogen, elixir-format
msgid "We have received your request. You already have a membership application that is being reviewed."
msgstr "Wir haben deine Anfrage erhalten. Du hast bereits einen Mitgliedsantrag, der geprüft wird."
#: lib/mv_web/templates/emails/join_confirmation.html.heex
#, elixir-autogen, elixir-format
msgid "You already had a pending request. Here is a new confirmation link."
msgstr "Du hattest bereits einen offenen Antrag. Hier ist ein neuer Bestätigungslink."
#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
#, elixir-autogen, elixir-format
msgid "Back to join form"
msgstr "Zurück zu den Mitgliedsanträgen"
#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
#, elixir-autogen, elixir-format
msgid "Go to join form"
msgstr "Zum Antragsformular"
#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
#, elixir-autogen, elixir-format
msgid "Invalid or expired link"
msgstr "Ungültiger oder abgelaufener Link."
#: lib/mv_web/controllers/join_confirm_controller.ex
#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
#, elixir-autogen, elixir-format
msgid "Link expired"
msgstr "Link abgelaufen"
#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
#, elixir-autogen, elixir-format
msgid "Submit new request"
msgstr "Antrag absenden"
#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
#, elixir-autogen, elixir-format
msgid "Thank you"
msgstr "Vielen Dank"
#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
#, elixir-autogen, elixir-format
msgid "You will receive an email once your application has been reviewed."
msgstr "Du erhältst eine E-Mail, sobald dein Antrag geprüft wurde."
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Allow direct registration (/register)"
msgstr "Direkte Registrierung erlauben (/register)"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Authentication"
msgstr "Anmeldung"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Direct registration"
msgstr "Direkte Registrierung"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Failed to update setting."
msgstr "Einstellung konnte nicht gespeichert werden."
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "If disabled, users cannot sign up via /register; sign-in and the join form remain available."
msgstr "Wenn deaktiviert, können sich Nutzer*innen nicht über /register anmelden; Anmeldung und Beitrittsformular bleiben verfügbar."
#: lib/mv_web/controllers/page_controller.ex
#, elixir-autogen, elixir-format
msgid "Home"
msgstr ""
#: lib/mv_web/controllers/join_confirm_controller.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Invalid link"
msgstr "Ungültiger oder abgelaufener Link."
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Join"
msgstr "Beitritt"
#: lib/mv_web/controllers/join_confirm_controller.ex
#, elixir-autogen, elixir-format
msgid "Join confirmation"
msgstr "Beitrittsbestätigung"
#: lib/mv_web/live/auth/sign_in_live.ex
#, elixir-autogen, elixir-format
msgid "Sign in"
msgstr "Anmelden"
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Group %{name}"
msgstr "Gruppe %{name}"
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Member %{name}"
msgstr "Mitglied %{name}"
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format
msgid "Role %{name}"
msgstr "Rolle %{name}"
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format
msgid "User %{email}"
msgstr "Benutzer*in %{email}"

View file

@ -111,11 +111,6 @@ msgstr ""
msgid "Add members" msgid "Add members"
msgstr "" msgstr ""
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Additional form data"
msgstr ""
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Address" msgid "Address"
@ -357,6 +352,7 @@ msgid "Base URL"
msgstr "" msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Basic settings" msgid "Basic settings"
msgstr "" msgstr ""
@ -1085,7 +1081,6 @@ msgid "Edit Group"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Edit Member" msgid "Edit Member"
msgstr "" msgstr ""
@ -1122,7 +1117,6 @@ msgstr ""
#: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/join_request_live/index.ex #: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
@ -1375,7 +1369,6 @@ msgid "First Name"
msgstr "" msgstr ""
#: lib/mv_web/live/join_request_live/index.ex #: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "First name" msgid "First name"
@ -1582,11 +1575,6 @@ msgstr ""
msgid "Import" msgid "Import"
msgstr "" msgstr ""
#: lib/mv_web/live/import_live.ex
#, elixir-autogen, elixir-format
msgid "Import Members"
msgstr ""
#: lib/mv_web/live/import_live/components.ex #: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Import Results" msgid "Import Results"
@ -1696,7 +1684,7 @@ msgstr ""
msgid "Invalid email address. Please enter a valid recipient address." msgid "Invalid email address. Please enter a valid recipient address."
msgstr "" msgstr ""
#: lib/mv_web/controllers/join_confirm_controller.ex #: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Invalid or expired link." msgid "Invalid or expired link."
msgstr "" msgstr ""
@ -1793,7 +1781,6 @@ msgid "Last Name"
msgstr "" msgstr ""
#: lib/mv_web/live/join_request_live/index.ex #: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Last name" msgid "Last name"
@ -1837,17 +1824,6 @@ msgstr ""
msgid "Linked User" msgid "Linked User"
msgstr "" msgstr ""
#: lib/mv_web/live/role_live/index.ex
#: lib/mv_web/live/role_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Listing Roles"
msgstr ""
#: lib/mv_web/live/user_live/index.ex
#, elixir-autogen, elixir-format
msgid "Listing Users"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Logout" msgid "Logout"
@ -2000,7 +1976,6 @@ msgstr ""
#: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_live/index.ex #: lib/mv_web/live/member_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/membership_fee_type_live/index.ex
#: lib/mv_web/live/statistics_live.ex #: lib/mv_web/live/statistics_live.ex
@ -2019,11 +1994,6 @@ msgstr ""
msgid "Membership Fee" msgid "Membership Fee"
msgstr "" msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Membership Fee Settings"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/translations/member_fields.ex #: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -2043,7 +2013,6 @@ msgid "Membership Fee Type"
msgstr "" msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Membership Fee Types" msgid "Membership Fee Types"
msgstr "" msgstr ""
@ -2055,6 +2024,8 @@ msgid "Membership Fees"
msgstr "" msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Membership fee settings" msgid "Membership fee settings"
msgstr "" msgstr ""
@ -2179,6 +2150,7 @@ msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_field_live/index_component.ex #: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/member_live/index/formatter.ex #: lib/mv_web/live/member_live/index/formatter.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
@ -2682,11 +2654,6 @@ msgstr ""
msgid "Reorder" msgid "Reorder"
msgstr "" msgstr ""
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Request data"
msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex #: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/global_settings_live.ex
@ -2712,18 +2679,12 @@ msgstr ""
msgid "Review by" msgid "Review by"
msgstr "" msgstr ""
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Review information"
msgstr ""
#: lib/mv_web/live/join_request_live/index.ex #: lib/mv_web/live/join_request_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Reviewed at" msgid "Reviewed at"
msgstr "" msgstr ""
#: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/role_live/form.ex
#: lib/mv_web/live/role_live/show.ex
#: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/index.html.heex #: lib/mv_web/live/user_live/index.html.heex
#: lib/mv_web/live/user_live/show.ex #: lib/mv_web/live/user_live/show.ex
@ -2753,6 +2714,7 @@ msgid "Role saved successfully."
msgstr "" msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/role_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Roles" msgid "Roles"
msgstr "" msgstr ""
@ -2989,11 +2951,6 @@ msgstr ""
msgid "Sets whether the payment status filter and the membership fee status column use the last completed or the current payment cycle." msgid "Sets whether the payment status filter and the membership fee status column use the last completed or the current payment cycle."
msgstr "" msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Settings"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Settings saved successfully." msgid "Settings saved successfully."
@ -3009,21 +2966,6 @@ msgstr ""
msgid "Show" msgid "Show"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Show Member"
msgstr ""
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format
msgid "Show Role"
msgstr ""
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format
msgid "Show User"
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 "Show bookings/receipts from Vereinfacht" msgid "Show bookings/receipts from Vereinfacht"
@ -3215,7 +3157,7 @@ msgstr ""
msgid "Text" msgid "Text"
msgstr "" msgstr ""
#: lib/mv_web/controllers/join_confirm_controller.ex #: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Thank you, we have received your request." msgid "Thank you, we have received your request."
msgstr "" msgstr ""
@ -3288,7 +3230,7 @@ msgstr ""
msgid "This is a test email sent from Mila. If you received this, your SMTP configuration is working correctly." msgid "This is a test email sent from Mila. If you received this, your SMTP configuration is working correctly."
msgstr "" msgstr ""
#: lib/mv_web/controllers/join_confirm_controller.ex #: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "This link has expired. Please submit the form again." msgid "This link has expired. Please submit the form again."
msgstr "" msgstr ""
@ -3323,7 +3265,7 @@ msgstr ""
msgid "To confirm deletion, please enter this text:" msgid "To confirm deletion, please enter this text:"
msgstr "" msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/components/core_components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Toggle dark mode" msgid "Toggle dark mode"
msgstr "" msgstr ""
@ -3408,7 +3350,6 @@ msgid "Use the data field name as the CSV column header in your file. Data field
msgstr "" msgstr ""
#: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "User" msgid "User"
msgstr "" msgstr ""
@ -3447,7 +3388,7 @@ msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/index.html.heex
#: lib/mv_web/live/user_live/index.html.heex #: lib/mv_web/live/user_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Users" msgid "Users"
msgstr "" msgstr ""
@ -3575,6 +3516,7 @@ msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_field_live/index_component.ex #: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/member_live/index/formatter.ex #: lib/mv_web/live/member_live/index/formatter.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
@ -3776,3 +3718,180 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "without %{name}" msgid "without %{name}"
msgstr "" msgstr ""
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Applicant data"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Copy"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Copy join page URL"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Join page URL"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Join page URL copied to clipboard."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Link to the public join page (share this with applicants):"
msgstr ""
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Status and review"
msgstr ""
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "We could not send the confirmation email. Please try again later or contact support."
msgstr ""
#: lib/mv_web/templates/emails/join_already_member.html.heex
#: lib/mv_web/templates/emails/join_already_pending.html.heex
#, elixir-autogen, elixir-format
msgid "If you have any questions, please contact us."
msgstr ""
#: lib/mv_web/emails/join_already_member_email.ex
#, elixir-autogen, elixir-format
msgid "Membership application already a member"
msgstr ""
#: lib/mv_web/emails/join_already_pending_email.ex
#, elixir-autogen, elixir-format
msgid "Membership application already under review"
msgstr ""
#: lib/mv_web/templates/emails/join_already_member.html.heex
#, elixir-autogen, elixir-format
msgid "We have received your request. The email address you entered is already registered as a member."
msgstr ""
#: lib/mv_web/templates/emails/join_already_pending.html.heex
#, elixir-autogen, elixir-format
msgid "We have received your request. You already have a membership application that is being reviewed."
msgstr ""
#: lib/mv_web/templates/emails/join_confirmation.html.heex
#, elixir-autogen, elixir-format
msgid "You already had a pending request. Here is a new confirmation link."
msgstr ""
#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
#, elixir-autogen, elixir-format
msgid "Back to join form"
msgstr ""
#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
#, elixir-autogen, elixir-format
msgid "Go to join form"
msgstr ""
#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
#, elixir-autogen, elixir-format
msgid "Invalid or expired link"
msgstr ""
#: lib/mv_web/controllers/join_confirm_controller.ex
#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
#, elixir-autogen, elixir-format
msgid "Link expired"
msgstr ""
#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
#, elixir-autogen, elixir-format
msgid "Submit new request"
msgstr ""
#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
#, elixir-autogen, elixir-format
msgid "Thank you"
msgstr ""
#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
#, elixir-autogen, elixir-format
msgid "You will receive an email once your application has been reviewed."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Allow direct registration (/register)"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Authentication"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Direct registration"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Failed to update setting."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "If disabled, users cannot sign up via /register; sign-in and the join form remain available."
msgstr ""
#: lib/mv_web/controllers/page_controller.ex
#, elixir-autogen, elixir-format
msgid "Home"
msgstr ""
#: lib/mv_web/controllers/join_confirm_controller.ex
#, elixir-autogen, elixir-format
msgid "Invalid link"
msgstr ""
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Join"
msgstr ""
#: lib/mv_web/controllers/join_confirm_controller.ex
#, elixir-autogen, elixir-format
msgid "Join confirmation"
msgstr ""
#: lib/mv_web/live/auth/sign_in_live.ex
#, elixir-autogen, elixir-format
msgid "Sign in"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Group %{name}"
msgstr ""
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Member %{name}"
msgstr ""
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format
msgid "Role %{name}"
msgstr ""
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format
msgid "User %{email}"
msgstr ""

View file

@ -132,18 +132,16 @@ msgid "This OIDC account is already linked to another user. Please contact suppo
msgstr "" msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex #: lib/mv_web/live/auth/link_oidc_account_live.ex
#: lib/mv_web/live/auth/sign_in_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Language selection" msgid "Language selection"
msgstr "" msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex #: lib/mv_web/live/auth/link_oidc_account_live.ex
#: lib/mv_web/live/auth/sign_in_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Select language" msgid "Select language"
msgstr "" msgstr ""
#: lib/mv_web/auth_overrides.ex #: lib/mv_web/live/auth/sign_in_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "or" msgid "Register"
msgstr "or" msgstr ""

View file

@ -111,11 +111,6 @@ msgstr ""
msgid "Add members" msgid "Add members"
msgstr "" msgstr ""
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Additional form data"
msgstr "Additional form data"
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Address" msgid "Address"
@ -357,6 +352,7 @@ msgid "Base URL"
msgstr "" msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Basic settings" msgid "Basic settings"
msgstr "" msgstr ""
@ -1085,7 +1081,6 @@ msgid "Edit Group"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Edit Member" msgid "Edit Member"
msgstr "" msgstr ""
@ -1122,7 +1117,6 @@ msgstr ""
#: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/join_request_live/index.ex #: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
@ -1375,7 +1369,6 @@ msgid "First Name"
msgstr "" msgstr ""
#: lib/mv_web/live/join_request_live/index.ex #: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "First name" msgid "First name"
@ -1582,11 +1575,6 @@ msgstr "If you did not submit this request, you can ignore this email."
msgid "Import" msgid "Import"
msgstr "" msgstr ""
#: lib/mv_web/live/import_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Import Members"
msgstr ""
#: lib/mv_web/live/import_live/components.ex #: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Import Results" msgid "Import Results"
@ -1696,7 +1684,7 @@ msgstr ""
msgid "Invalid email address. Please enter a valid recipient address." msgid "Invalid email address. Please enter a valid recipient address."
msgstr "" msgstr ""
#: lib/mv_web/controllers/join_confirm_controller.ex #: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Invalid or expired link." msgid "Invalid or expired link."
msgstr "Invalid or expired link." msgstr "Invalid or expired link."
@ -1793,7 +1781,6 @@ msgid "Last Name"
msgstr "" msgstr ""
#: lib/mv_web/live/join_request_live/index.ex #: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Last name" msgid "Last name"
@ -1837,17 +1824,6 @@ msgstr ""
msgid "Linked User" msgid "Linked User"
msgstr "" msgstr ""
#: lib/mv_web/live/role_live/index.ex
#: lib/mv_web/live/role_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy
msgid "Listing Roles"
msgstr ""
#: lib/mv_web/live/user_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Listing Users"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Logout" msgid "Logout"
@ -2000,7 +1976,6 @@ msgstr ""
#: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_live/index.ex #: lib/mv_web/live/member_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/membership_fee_type_live/index.ex
#: lib/mv_web/live/statistics_live.ex #: lib/mv_web/live/statistics_live.ex
@ -2019,11 +1994,6 @@ msgstr ""
msgid "Membership Fee" msgid "Membership Fee"
msgstr "" msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Membership Fee Settings"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/translations/member_fields.ex #: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
@ -2043,7 +2013,6 @@ msgid "Membership Fee Type"
msgstr "" msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Membership Fee Types" msgid "Membership Fee Types"
msgstr "" msgstr ""
@ -2055,6 +2024,8 @@ msgid "Membership Fees"
msgstr "" msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Membership fee settings" msgid "Membership fee settings"
msgstr "" msgstr ""
@ -2179,6 +2150,7 @@ msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_field_live/index_component.ex #: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/member_live/index/formatter.ex #: lib/mv_web/live/member_live/index/formatter.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
@ -2682,11 +2654,6 @@ msgstr ""
msgid "Reorder" msgid "Reorder"
msgstr "Reorder" msgstr "Reorder"
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Request data"
msgstr "Request data"
#: lib/mv_web/live/custom_field_live/form_component.ex #: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/global_settings_live.ex
@ -2712,18 +2679,12 @@ msgstr "Reset your password"
msgid "Review by" msgid "Review by"
msgstr "Review by" msgstr "Review by"
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Review information"
msgstr "Review information"
#: lib/mv_web/live/join_request_live/index.ex #: lib/mv_web/live/join_request_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Reviewed at" msgid "Reviewed at"
msgstr "Review date" msgstr "Review date"
#: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/role_live/form.ex
#: lib/mv_web/live/role_live/show.ex
#: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/index.html.heex #: lib/mv_web/live/user_live/index.html.heex
#: lib/mv_web/live/user_live/show.ex #: lib/mv_web/live/user_live/show.ex
@ -2753,6 +2714,7 @@ msgid "Role saved successfully."
msgstr "" msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/role_live/index.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Roles" msgid "Roles"
msgstr "" msgstr ""
@ -2989,11 +2951,6 @@ msgstr ""
msgid "Sets whether the payment status filter and the membership fee status column use the last completed or the current payment cycle." msgid "Sets whether the payment status filter and the membership fee status column use the last completed or the current payment cycle."
msgstr "Sets whether the payment status filter and the membership fee status column use the last completed or the current payment cycle." msgstr "Sets whether the payment status filter and the membership fee status column use the last completed or the current payment cycle."
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Settings"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Settings saved successfully." msgid "Settings saved successfully."
@ -3009,21 +2966,6 @@ msgstr ""
msgid "Show" msgid "Show"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Show Member"
msgstr ""
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Show Role"
msgstr ""
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Show User"
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 "Show bookings/receipts from Vereinfacht" msgid "Show bookings/receipts from Vereinfacht"
@ -3215,7 +3157,7 @@ msgstr ""
msgid "Text" msgid "Text"
msgstr "" msgstr ""
#: lib/mv_web/controllers/join_confirm_controller.ex #: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Thank you, we have received your request." msgid "Thank you, we have received your request."
msgstr "Thank you, we have received your request." msgstr "Thank you, we have received your request."
@ -3288,7 +3230,7 @@ msgstr ""
msgid "This is a test email sent from Mila. If you received this, your SMTP configuration is working correctly." msgid "This is a test email sent from Mila. If you received this, your SMTP configuration is working correctly."
msgstr "" msgstr ""
#: lib/mv_web/controllers/join_confirm_controller.ex #: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "This link has expired. Please submit the form again." msgid "This link has expired. Please submit the form again."
msgstr "This link has expired. Please submit the form again." msgstr "This link has expired. Please submit the form again."
@ -3323,7 +3265,7 @@ msgstr ""
msgid "To confirm deletion, please enter this text:" msgid "To confirm deletion, please enter this text:"
msgstr "" msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/components/core_components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Toggle dark mode" msgid "Toggle dark mode"
msgstr "" msgstr ""
@ -3408,7 +3350,6 @@ msgid "Use the data field name as the CSV column header in your file. Data field
msgstr "" msgstr ""
#: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "User" msgid "User"
msgstr "" msgstr ""
@ -3447,7 +3388,7 @@ msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/index.html.heex
#: lib/mv_web/live/user_live/index.html.heex #: lib/mv_web/live/user_live/index.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Users" msgid "Users"
msgstr "" msgstr ""
@ -3575,6 +3516,7 @@ msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_field_live/index_component.ex #: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/member_live/index/formatter.ex #: lib/mv_web/live/member_live/index/formatter.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
@ -3776,3 +3718,180 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "without %{name}" msgid "without %{name}"
msgstr "without %{name}" msgstr "without %{name}"
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Applicant data"
msgstr "Applicant data"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Copy"
msgstr "Copy"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Copy join page URL"
msgstr "Copy join page URL"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Join page URL"
msgstr "Join page URL"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Join page URL copied to clipboard."
msgstr "Join page URL copied to clipboard."
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Link to the public join page (share this with applicants):"
msgstr "Link to the public join page (share this with applicants):"
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Status and review"
msgstr "Status and review"
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "We could not send the confirmation email. Please try again later or contact support."
msgstr ""
#: lib/mv_web/templates/emails/join_already_member.html.heex
#: lib/mv_web/templates/emails/join_already_pending.html.heex
#, elixir-autogen, elixir-format
msgid "If you have any questions, please contact us."
msgstr "If you have any questions, please contact us."
#: lib/mv_web/emails/join_already_member_email.ex
#, elixir-autogen, elixir-format
msgid "Membership application already a member"
msgstr "Membership application already a member"
#: lib/mv_web/emails/join_already_pending_email.ex
#, elixir-autogen, elixir-format
msgid "Membership application already under review"
msgstr "Membership application already under review"
#: lib/mv_web/templates/emails/join_already_member.html.heex
#, elixir-autogen, elixir-format
msgid "We have received your request. The email address you entered is already registered as a member."
msgstr "We have received your request. The email address you entered is already registered as a member."
#: lib/mv_web/templates/emails/join_already_pending.html.heex
#, elixir-autogen, elixir-format
msgid "We have received your request. You already have a membership application that is being reviewed."
msgstr "We have received your request. You already have a membership application that is being reviewed."
#: lib/mv_web/templates/emails/join_confirmation.html.heex
#, elixir-autogen, elixir-format
msgid "You already had a pending request. Here is a new confirmation link."
msgstr "You already had a pending request. Here is a new confirmation link."
#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
#, elixir-autogen, elixir-format
msgid "Back to join form"
msgstr "Back to membership applications"
#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
#, elixir-autogen, elixir-format
msgid "Go to join form"
msgstr "Go to join form"
#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
#, elixir-autogen, elixir-format
msgid "Invalid or expired link"
msgstr "Invalid or expired link."
#: lib/mv_web/controllers/join_confirm_controller.ex
#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
#, elixir-autogen, elixir-format
msgid "Link expired"
msgstr "Link expired"
#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
#, elixir-autogen, elixir-format
msgid "Submit new request"
msgstr "Submit new request"
#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
#, elixir-autogen, elixir-format
msgid "Thank you"
msgstr "Thank you"
#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
#, elixir-autogen, elixir-format
msgid "You will receive an email once your application has been reviewed."
msgstr "You will receive an email once your application has been reviewed."
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Allow direct registration (/register)"
msgstr "Allow direct registration (/register)"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Authentication"
msgstr "Authentication"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Direct registration"
msgstr "Direct registration"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Failed to update setting."
msgstr "Failed to update setting."
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "If disabled, users cannot sign up via /register; sign-in and the join form remain available."
msgstr "If disabled, users cannot sign up via /register; sign-in and the join form remain available."
#: lib/mv_web/controllers/page_controller.ex
#, elixir-autogen, elixir-format
msgid "Home"
msgstr ""
#: lib/mv_web/controllers/join_confirm_controller.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Invalid link"
msgstr "Invalid or expired link."
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Join"
msgstr ""
#: lib/mv_web/controllers/join_confirm_controller.ex
#, elixir-autogen, elixir-format
msgid "Join confirmation"
msgstr ""
#: lib/mv_web/live/auth/sign_in_live.ex
#, elixir-autogen, elixir-format
msgid "Sign in"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Group %{name}"
msgstr "Group %{name}"
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Member %{name}"
msgstr "Member %{name}"
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format
msgid "Role %{name}"
msgstr "Role %{name}"
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format
msgid "User %{email}"
msgstr "User %{email}"

View file

@ -0,0 +1,30 @@
defmodule Mv.Repo.Migrations.AddReviewedByDisplayToJoinRequests do
@moduledoc """
Adds reviewed_by_display to join_requests for showing reviewer in UI without loading User.
Backfills existing rows from users.email where reviewed_by_user_id is set.
"""
use Ecto.Migration
def up do
alter table(:join_requests) do
add :reviewed_by_display, :text
end
# Backfill from users.email for rows that have reviewed_by_user_id
execute """
UPDATE join_requests j
SET reviewed_by_display = u.email
FROM users u
WHERE j.reviewed_by_user_id = u.id
AND j.reviewed_by_user_id IS NOT NULL
"""
end
def down do
alter table(:join_requests) do
remove :reviewed_by_display
end
end
end

View file

@ -0,0 +1,20 @@
defmodule Mv.Repo.Migrations.AddRegistrationEnabledToSettings do
@moduledoc """
Adds registration_enabled flag to settings. When false, direct registration
via /register is disabled; sign-in and join form remain available.
"""
use Ecto.Migration
def up do
alter table(:settings) do
add :registration_enabled, :boolean, default: true, null: false
end
end
def down do
alter table(:settings) do
remove :registration_enabled
end
end
end

View file

@ -263,6 +263,21 @@ default_hidden_in_overview = %{
"membership_fee_start_date" => false "membership_fee_start_date" => false
} }
# Default join form field selection (email + name + address + join_date); join form stays disabled.
default_join_form_field_ids = [
"email",
"first_name",
"last_name",
"street",
"house_number",
"postal_code",
"city",
"country",
"join_date"
]
default_join_form_field_required = %{"email" => true}
case Membership.get_settings() do case Membership.get_settings() do
{:ok, existing_settings} -> {:ok, existing_settings} ->
updates = updates =
@ -304,7 +319,9 @@ case Membership.get_settings() do
|> Ash.Changeset.for_create(:create, %{ |> Ash.Changeset.for_create(:create, %{
club_name: default_club_name, club_name: default_club_name,
member_field_visibility: default_hidden_in_overview, member_field_visibility: default_hidden_in_overview,
default_membership_fee_type_id: default_fee_type.id default_membership_fee_type_id: default_fee_type.id,
join_form_field_ids: default_join_form_field_ids,
join_form_field_required: default_join_form_field_required
}) })
|> Ash.create!() |> Ash.create!()
end end

View file

@ -481,19 +481,28 @@ for {email, values} <- custom_value_assignments do
end end
end end
# Join form: enable so membership application list is visible in dev # Join form: enable so membership application list is visible in dev; default field list includes address + join_date
default_join_form_field_ids = [
"email",
"first_name",
"last_name",
"street",
"house_number",
"postal_code",
"city",
"country",
"join_date"
]
default_join_form_field_required = %{"email" => true}
case Membership.get_settings() do case Membership.get_settings() do
{:ok, settings} -> {:ok, settings} ->
unless settings.join_form_enabled do unless settings.join_form_enabled do
Membership.update_settings(settings, %{ Membership.update_settings(settings, %{
join_form_enabled: true, join_form_enabled: true,
join_form_field_ids: settings.join_form_field_ids || ["email", "first_name", "last_name", "city"], join_form_field_ids: settings.join_form_field_ids || default_join_form_field_ids,
join_form_field_required: settings.join_form_field_required || %{ join_form_field_required: settings.join_form_field_required || default_join_form_field_required
"email" => true,
"first_name" => false,
"last_name" => false,
"city" => false
}
}) })
end end
_ -> _ ->

View file

@ -67,6 +67,18 @@ defmodule Mv.Membership.JoinRequestApprovalDomainTest do
end end
end end
describe "reviewed_by_display" do
test "get_join_request returns reviewed_by_display so UI can show reviewer without loading User" do
request = Fixtures.submitted_join_request_fixture()
reviewer = Fixtures.user_with_role_fixture("normal_user")
assert {:ok, _} = Membership.approve_join_request(request.id, actor: reviewer)
assert {:ok, loaded} = Membership.get_join_request(request.id, actor: reviewer)
assert loaded.reviewed_by_display == to_string(reviewer.email)
end
end
describe "reject_join_request/2" do describe "reject_join_request/2" do
test "reject does not create a member" do test "reject does not create a member" do
request = Fixtures.submitted_join_request_fixture() request = Fixtures.submitted_join_request_fixture()

View file

@ -49,6 +49,7 @@ defmodule Mv.Membership.JoinRequestApprovalPolicyTest do
assert approved.status == :approved assert approved.status == :approved
assert approved.approved_at != nil assert approved.approved_at != nil
assert approved.reviewed_by_user_id == user.id assert approved.reviewed_by_user_id == user.id
assert approved.reviewed_by_display == to_string(user.email)
end end
test "admin can approve a submitted join request", %{request: request} do test "admin can approve a submitted join request", %{request: request} do
@ -89,6 +90,7 @@ defmodule Mv.Membership.JoinRequestApprovalPolicyTest do
assert rejected.status == :rejected assert rejected.status == :rejected
assert rejected.rejected_at != nil assert rejected.rejected_at != nil
assert rejected.reviewed_by_user_id == user.id assert rejected.reviewed_by_user_id == user.id
assert rejected.reviewed_by_display == to_string(user.email)
end end
test "admin can reject a submitted join request", %{request: request} do test "admin can reject a submitted join request", %{request: request} do

View file

@ -0,0 +1,33 @@
defmodule Mv.Membership.JoinRequestSubmitEmailFailureTest do
@moduledoc """
Tests that when join confirmation email delivery fails, the domain returns
{:error, :email_delivery_failed} (and the LiveView shows an error). Uses
FailingMailAdapter to simulate delivery failure; async: false to avoid config races.
"""
use Mv.DataCase, async: false
alias Mv.Membership
@valid_submit_attrs %{
email: "fail#{System.unique_integer([:positive])}@example.com"
}
test "submit_join_request returns {:error, :email_delivery_failed} when mail delivery fails" do
saved = Application.get_env(:mv, Mv.Mailer)
Application.put_env(
:mv,
Mv.Mailer,
Keyword.put(saved || [], :adapter, Mv.TestSupport.FailingMailAdapter)
)
on_exit(fn ->
Application.put_env(:mv, Mv.Mailer, saved)
end)
token = "fail-token-#{System.unique_integer([:positive])}"
attrs = Map.put(@valid_submit_attrs, :confirmation_token, token)
assert {:error, :email_delivery_failed} = Membership.submit_join_request(attrs, actor: nil)
end
end

View file

@ -12,7 +12,12 @@ defmodule Mv.Membership.JoinRequestTest do
""" """
use Mv.DataCase, async: true use Mv.DataCase, async: true
require Ash.Query
import Ash.Expr
alias Mv.Fixtures
alias Mv.Membership alias Mv.Membership
alias Mv.Membership.JoinRequest
# Valid minimal attributes for submit (email required; confirmation_token optional for tests) # Valid minimal attributes for submit (email required; confirmation_token optional for tests)
@valid_submit_attrs %{ @valid_submit_attrs %{
@ -136,6 +141,60 @@ defmodule Mv.Membership.JoinRequestTest do
end end
end end
describe "submit_join_request/2 anti-enumeration (already member / already pending)" do
test "returns {:ok, :notified_already_member} and creates no JoinRequest when email is already a member" do
member =
Fixtures.member_fixture(%{
email: "already_member#{System.unique_integer([:positive])}@example.com"
})
attrs = %{
email: member.email,
confirmation_token: "token-#{System.unique_integer([:positive])}"
}
assert {:ok, :notified_already_member} = Membership.submit_join_request(attrs, actor: nil)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, requests} =
JoinRequest
|> Ash.Query.filter(expr(email == ^member.email))
|> Ash.read(actor: system_actor, domain: Mv.Membership)
assert requests == []
end
test "returns {:ok, :notified_already_pending} and does not create duplicate when same email submits again (resend)" do
email = "resend#{System.unique_integer([:positive])}@example.com"
token1 = "first-token-#{System.unique_integer([:positive])}"
attrs1 = %{email: email, confirmation_token: token1}
assert {:ok, request1} = Membership.submit_join_request(attrs1, actor: nil)
assert request1.status == :pending_confirmation
attrs2 = %{
email: email,
confirmation_token: "second-token-#{System.unique_integer([:positive])}"
}
assert {:ok, :notified_already_pending} = Membership.submit_join_request(attrs2, actor: nil)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, requests} =
JoinRequest
|> Ash.Query.filter(expr(email == ^email))
|> Ash.read(actor: system_actor, domain: Mv.Membership)
assert length(requests) == 1
assert hd(requests).id == request1.id
# Resend path updates the request (new token stored); confirmation_sent_at will have been set/updated
assert hd(requests).confirmation_sent_at != nil
end
end
describe "allowlist (server-side field filter)" do describe "allowlist (server-side field filter)" do
test "submit with non-allowlisted form_data keys does not persist those keys" do test "submit with non-allowlisted form_data keys does not persist those keys" do
# Allowlist restricts which fields are accepted; extra keys must not be stored. # Allowlist restricts which fields are accepted; extra keys must not be stored.

View file

@ -56,13 +56,6 @@ defmodule Mv.Membership.SettingJoinFormTest do
Membership.update_settings(settings, attrs) Membership.update_settings(settings, attrs)
end end
defp error_message(errors, field) when is_atom(field) do
errors
|> Enum.filter(fn err -> Map.get(err, :field) == field end)
|> Enum.map(&Map.get(&1, :message, ""))
|> List.first() || ""
end
# ---- 1. Persistence and loading ---- # ---- 1. Persistence and loading ----
describe "join form settings persistence and loading" do describe "join form settings persistence and loading" do

View file

@ -4,6 +4,8 @@ defmodule MvWeb.AuthControllerTest do
import Phoenix.ConnTest import Phoenix.ConnTest
import ExUnit.CaptureLog import ExUnit.CaptureLog
alias Mv.Membership
# Helper to create an unauthenticated conn (preserves sandbox metadata) # Helper to create an unauthenticated conn (preserves sandbox metadata)
defp build_unauthenticated_conn(authenticated_conn) do defp build_unauthenticated_conn(authenticated_conn) do
# Create new conn but preserve sandbox metadata for database access # Create new conn but preserve sandbox metadata for database access
@ -28,6 +30,16 @@ defmodule MvWeb.AuthControllerTest do
assert html_response(conn, 200) =~ "Sign in" assert html_response(conn, 200) =~ "Sign in"
end end
@tag role: :unauthenticated
test "GET /sign-in returns 200 and renders page (exercises AuthOverrides and layout)", %{
conn: conn
} do
{:ok, _view, html} = live(conn, ~p"/sign-in")
assert html =~ "Sign in"
# Public header (logo) from Layouts.app unauthenticated branch
assert html =~ "mila.svg" or html =~ "Mila Logo"
end
test "GET /sign-out redirects to home", %{conn: authenticated_conn} do test "GET /sign-out redirects to home", %{conn: authenticated_conn} do
conn = conn_with_oidc_user(authenticated_conn) conn = conn_with_oidc_user(authenticated_conn)
conn = get(conn, ~p"/sign-out") conn = get(conn, ~p"/sign-out")
@ -159,6 +171,23 @@ defmodule MvWeb.AuthControllerTest do
assert html =~ "length must be greater than or equal to 8" assert html =~ "length must be greater than or equal to 8"
end end
test "when registration is disabled, sign-in page does not show Need an account? toggle", %{
conn: authenticated_conn
} do
{:ok, settings} = Membership.get_settings()
original = Map.get(settings, :registration_enabled, true)
{:ok, _} = Membership.update_settings(settings, %{registration_enabled: false})
try do
conn = build_unauthenticated_conn(authenticated_conn)
{:ok, _view, html} = live(conn, ~p"/sign-in")
refute html =~ "Need an account?"
after
{:ok, s} = Membership.get_settings()
Membership.update_settings(s, %{registration_enabled: original})
end
end
# Access control # Access control
test "unauthenticated user accessing protected route gets redirected to sign-in", %{ test "unauthenticated user accessing protected route gets redirected to sign-in", %{
conn: authenticated_conn conn: authenticated_conn

View file

@ -0,0 +1,63 @@
defmodule MvWeb.Helpers.DateFormatterTest do
@moduledoc """
Tests for DateFormatter: date/datetime formatting and timezone conversion for display.
"""
use ExUnit.Case, async: true
alias MvWeb.Helpers.DateFormatter
describe "format_date/1" do
test "formats Date to European format (dd.mm.yyyy)" do
assert DateFormatter.format_date(~D[2024-03-15]) == "15.03.2024"
end
test "returns empty string for nil" do
assert DateFormatter.format_date(nil) == ""
end
test "returns 'Invalid date' for non-Date" do
assert DateFormatter.format_date("2024-03-15") == "Invalid date"
end
end
describe "format_datetime/1 and format_datetime/2" do
test "formats UTC DateTime without timezone (European format)" do
dt = ~U[2024-03-15 10:30:00Z]
assert DateFormatter.format_datetime(dt) == "15.03.2024 10:30"
end
test "format_datetime with nil timezone same as no timezone (UTC)" do
dt = ~U[2024-03-15 10:30:00Z]
assert DateFormatter.format_datetime(dt, nil) == "15.03.2024 10:30"
end
test "formats DateTime in Europe/Berlin (CET/CEST)" do
# Winter: 10:30 UTC = 11:30 CET (UTC+1)
dt = ~U[2024-01-15 10:30:00Z]
assert DateFormatter.format_datetime(dt, "Europe/Berlin") == "15.01.2024 11:30"
# Summer: 10:30 UTC = 12:30 CEST (UTC+2)
dt_summer = ~U[2024-07-15 10:30:00Z]
assert DateFormatter.format_datetime(dt_summer, "Europe/Berlin") == "15.07.2024 12:30"
end
test "empty string timezone falls back to UTC" do
dt = ~U[2024-03-15 10:30:00Z]
assert DateFormatter.format_datetime(dt, "") == "15.03.2024 10:30"
end
test "invalid timezone falls back to UTC" do
dt = ~U[2024-03-15 10:30:00Z]
assert DateFormatter.format_datetime(dt, "Invalid/Zone") == "15.03.2024 10:30"
end
test "returns empty string for nil datetime" do
assert DateFormatter.format_datetime(nil) == ""
assert DateFormatter.format_datetime(nil, "Europe/Berlin") == ""
end
test "returns 'Invalid datetime' for non-DateTime" do
assert DateFormatter.format_datetime("2024-03-15 10:30") == "Invalid datetime"
end
end
end

View file

@ -144,8 +144,8 @@ defmodule MvWeb.GroupLive.IndexTest do
# Verify query count is reasonable (should avoid N+1 queries) # Verify query count is reasonable (should avoid N+1 queries)
# Expected: 1 query for groups list + 1 batch query for member counts + LiveView setup queries # Expected: 1 query for groups list + 1 batch query for member counts + LiveView setup queries
# Allow overhead for authorization, LiveView setup, and other initialization queries # Allow overhead for authorization, LiveView setup, and other initialization queries
assert final_count <= 12, assert final_count <= 13,
"Expected max 12 queries (groups list + batch member counts + LiveView setup + auth), got #{final_count}. This suggests N+1 query problem." "Expected max 13 queries (groups list + batch member counts + LiveView setup + auth), got #{final_count}. This suggests N+1 query problem."
end end
test "member count is loaded efficiently via calculation", %{conn: conn} do test "member count is loaded efficiently via calculation", %{conn: conn} do
@ -185,8 +185,8 @@ defmodule MvWeb.GroupLive.IndexTest do
# Verify query count is reasonable (member count should be calculated efficiently) # Verify query count is reasonable (member count should be calculated efficiently)
# Expected: 1 query for groups + 1 batch query for member counts + LiveView setup queries # Expected: 1 query for groups + 1 batch query for member counts + LiveView setup queries
# Allow overhead for authorization, LiveView setup, and other initialization queries # Allow overhead for authorization, LiveView setup, and other initialization queries
assert final_count <= 12, assert final_count <= 13,
"Expected max 12 queries (groups + batch member counts + LiveView setup + auth), got #{final_count}. This suggests inefficient member count calculation." "Expected max 13 queries (groups + batch member counts + LiveView setup + auth), got #{final_count}. This suggests inefficient member count calculation."
end end
end end
end end

View file

@ -253,8 +253,8 @@ defmodule MvWeb.GroupLive.ShowTest do
# Verify query count is reasonable (should avoid N+1 queries). # Verify query count is reasonable (should avoid N+1 queries).
# Baseline: group + members preload + member_count aggregate + 1 layout get_settings + auth/role/join-count. # Baseline: group + members preload + member_count aggregate + 1 layout get_settings + auth/role/join-count.
assert final_count <= 22, assert final_count <= 23,
"Expected max 22 queries (group + members preload + member_count + layout + auth), got #{final_count}. This suggests N+1 query problem." "Expected max 23 queries (group + members preload + member_count + layout + auth), got #{final_count}. This suggests N+1 query problem."
end end
test "slug lookup is efficient (uses unique_slug index)", %{conn: conn} do test "slug lookup is efficient (uses unique_slug index)", %{conn: conn} do

View file

@ -0,0 +1,58 @@
defmodule MvWeb.JoinLiveEmailFailureTest do
@moduledoc """
When join confirmation email delivery fails, the user sees an error message
and no success copy. Uses FailingMailAdapter; async: false to avoid config races.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.Membership
@tag role: :unauthenticated
test "when confirmation email fails, user sees error flash and no success message", %{
conn: conn
} do
enable_join_form_for_test()
saved = Application.get_env(:mv, Mv.Mailer)
Application.put_env(
:mv,
Mv.Mailer,
Keyword.put(saved || [], :adapter, Mv.TestSupport.FailingMailAdapter)
)
on_exit(fn ->
Application.put_env(:mv, Mv.Mailer, saved)
end)
{:ok, view, _html} = live(conn, "/join")
# Use the HTML returned by render_submit (updated DOM including flash)
html =
view
|> form("#join-form", %{
"email" => "fail#{System.unique_integer([:positive])}@example.com",
"first_name" => "Jane",
"last_name" => "Doe",
"website" => ""
})
|> render_submit()
# Error message is translated; accept English or German wording
assert html =~ "could not send" or html =~ "confirmation email" or
html =~ "konnte nicht" or html =~ "Bestätigungs-E-Mail"
refute view |> element("[data-testid='join-success-message']") |> has_element?()
end
defp enable_join_form_for_test do
{:ok, settings} = Membership.get_settings()
Membership.update_settings(settings, %{
join_form_enabled: true,
join_form_field_ids: ["email", "first_name", "last_name"],
join_form_field_required: %{"email" => true, "first_name" => false, "last_name" => false}
})
end
end

View file

@ -9,7 +9,8 @@ defmodule MvWeb.JoinLiveTest do
Honeypot: form param `"website"` (legit-sounding name per best practice; not "honeypot"). Honeypot: form param `"website"` (legit-sounding name per best practice; not "honeypot").
Field is hidden via CSS class in app.css (off-screen, no inline styles), type="text". Field is hidden via CSS class in app.css (off-screen, no inline styles), type="text".
""" """
use MvWeb.ConnCase, async: true # async: false → shared sandbox; all processes (including LiveView) share the DB connection.
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest import Phoenix.LiveViewTest
import Ecto.Query import Ecto.Query
@ -33,14 +34,15 @@ defmodule MvWeb.JoinLiveTest do
end end
describe "submit join form" do describe "submit join form" do
setup :enable_join_form_for_test setup context do
reset_rate_limiter()
enable_join_form_for_test(context)
end
@tag role: :unauthenticated @tag role: :unauthenticated
test "submit with valid allowlist data creates one JoinRequest and shows success copy", %{ test "submit with valid allowlist data creates one JoinRequest and shows success copy", %{
conn: conn conn: conn
} do } do
# Re-apply allowlist so this test is robust when run in parallel with others (Settings singleton).
enable_join_form_for_test(%{})
count_before = count_join_requests() count_before = count_join_requests()
{:ok, view, _html} = live(conn, "/join") {:ok, view, _html} = live(conn, "/join")
@ -53,6 +55,9 @@ defmodule MvWeb.JoinLiveTest do
}) })
|> render_submit() |> render_submit()
# Anti-enumeration delay is applied in LiveView via send_after (100300 ms); wait for success UI.
Process.sleep(400)
assert count_join_requests() == count_before + 1 assert count_join_requests() == count_before + 1
assert view |> element("[data-testid='join-success-message']") |> has_element?() assert view |> element("[data-testid='join-success-message']") |> has_element?()
assert render(view) =~ "saved your details" assert render(view) =~ "saved your details"
@ -84,23 +89,6 @@ defmodule MvWeb.JoinLiveTest do
test "after rate limit exceeded submit returns 429 or error and no new JoinRequest", %{ test "after rate limit exceeded submit returns 429 or error and no new JoinRequest", %{
conn: conn conn: conn
} do } do
# Reset rate limit state so this test is independent of others (same key in test)
try do
:ets.delete_all_objects(MvWeb.JoinRateLimit)
rescue
ArgumentError -> :ok
end
enable_join_form(true)
# Set allowlist so form has email, first_name, last_name
{:ok, settings} = Membership.get_settings()
Membership.update_settings(settings, %{
join_form_field_ids: ["email", "first_name", "last_name"],
join_form_field_required: %{"email" => true, "first_name" => false, "last_name" => false}
})
# Rely on test config: join rate limit low (e.g. 2 per window)
base_email = "ratelimit#{System.unique_integer([:positive])}@example.com" base_email = "ratelimit#{System.unique_integer([:positive])}@example.com"
count_before = count_join_requests() count_before = count_join_requests()
sandbox = conn.private[:ecto_sandbox] sandbox = conn.private[:ecto_sandbox]
@ -168,4 +156,10 @@ defmodule MvWeb.JoinLiveTest do
defp count_join_requests do defp count_join_requests do
Repo.one(from j in "join_requests", select: count(j.id)) || 0 Repo.one(from j in "join_requests", select: count(j.id)) || 0
end end
defp reset_rate_limiter do
:ets.delete_all_objects(MvWeb.JoinRateLimit)
rescue
ArgumentError -> :ok
end
end end

View file

@ -70,7 +70,9 @@ defmodule MvWeb.MemberLive.IndexGroupsFilterTest do
# Force LiveView to process {:group_filter_changed, ...} (render triggers mailbox processing) # Force LiveView to process {:group_filter_changed, ...} (render triggers mailbox processing)
_ = render(view) _ = render(view)
assert_patch(view) # Wait for patch; return path so callers can assert URL contains expected filter param
path = assert_patch(view)
{view, path}
end end
test "filter All (default) shows all members", %{ test "filter All (default) shows all members", %{
@ -96,7 +98,8 @@ defmodule MvWeb.MemberLive.IndexGroupsFilterTest do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members") {:ok, view, _html} = live(conn, "/members")
open_filter_and_set_group(view, group1.id, "in") {view, path} = open_filter_and_set_group(view, group1.id, "in")
assert path =~ "group_#{group1.id}=in", "expected URL to contain group filter param"
html = render(view) html = render(view)
assert html =~ m1.first_name assert html =~ m1.first_name
@ -114,7 +117,7 @@ defmodule MvWeb.MemberLive.IndexGroupsFilterTest do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members") {:ok, view, _html} = live(conn, "/members")
open_filter_and_set_group(view, group1.id, "not_in") {view, _path} = open_filter_and_set_group(view, group1.id, "not_in")
html = render(view) html = render(view)
refute html =~ m1.first_name refute html =~ m1.first_name
@ -132,7 +135,7 @@ defmodule MvWeb.MemberLive.IndexGroupsFilterTest do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members") {:ok, view, _html} = live(conn, "/members")
open_filter_and_set_group(view, group1.id, "in") {view, _path} = open_filter_and_set_group(view, group1.id, "in")
html = render(view) html = render(view)
assert html =~ m1.first_name assert html =~ m1.first_name

View file

@ -24,16 +24,15 @@ defmodule MvWeb.UserLive.IndexTest do
@tag :ui @tag :ui
test "shows translated titles in different locales", %{conn: conn} do test "shows translated titles in different locales", %{conn: conn} do
# Test German translation # Page title/heading uses sidebar label (Users / Benutzer*innen), not "Listing Users"
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
conn = Plug.Test.init_test_session(conn, locale: "de") conn = Plug.Test.init_test_session(conn, locale: "de")
{:ok, _view, html_de} = live(conn, "/users") {:ok, _view, html_de} = live(conn, "/users")
assert html_de =~ "Benutzer*innen auflisten" assert html_de =~ "Benutzer*innen"
# Test English translation
conn = Plug.Test.init_test_session(conn, locale: "en") conn = Plug.Test.init_test_session(conn, locale: "en")
{:ok, _view, html_en} = live(conn, "/users") {:ok, _view, html_en} = live(conn, "/users")
assert html_en =~ "Listing Users" assert html_en =~ "Users"
end end
end end

View file

@ -0,0 +1,10 @@
defmodule Mv.TestSupport.FailingMailAdapter do
@moduledoc """
Swoosh adapter that always returns delivery failure. Used in tests to assert
that join confirmation email failure is handled (error shown to user, no success UI).
"""
use Swoosh.Adapter
@impl true
def deliver(_email, _config), do: {:error, :forced}
end