Finalize join request feature #472
99 changed files with 3160 additions and 1244 deletions
24
CHANGELOG.md
24
CHANGELOG.md
|
|
@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
## [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 user’s 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 applicant’s 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
|
||||
- **Roles and Permissions System (RBAC)** - Complete implementation (#345, 2026-01-08)
|
||||
- Four hardcoded permission sets: `own_data`, `read_only`, `normal_user`, `admin`
|
||||
|
|
|
|||
|
|
@ -90,6 +90,8 @@ lib/
|
|||
│ ├── custom_field.ex # Custom field (definition) resource
|
||||
│ ├── custom_field_value.ex # Custom field value resource
|
||||
│ ├── 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.)
|
||||
│ ├── group.ex # Group 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).
|
||||
- **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`).
|
||||
- `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).
|
||||
|
|
@ -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.
|
||||
|
||||
**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):**
|
||||
|
||||
- All transactional emails (join confirmation, user confirmation, password reset) use the same layout: `MvWeb.EmailLayoutView` (layout) and `MvWeb.EmailsView` (body templates).
|
||||
|
|
|
|||
|
|
@ -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"`).
|
||||
|
||||
### 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:** AshAuthentication’s `_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 AshAuthentication’s 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 library’s 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)
|
||||
|
||||
Use these standard roles:
|
||||
|
|
@ -83,16 +98,18 @@ Use these standard roles:
|
|||
| Role | Use | Class |
|
||||
|---|---|---|
|
||||
| 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` |
|
||||
| Helper text | under inputs | `text-sm text-base-content/70` |
|
||||
| Fine print | small hints | `text-xs text-base-content/60` |
|
||||
| Empty state | no data | `text-base-content/60 italic` |
|
||||
| Helper text | under inputs | `text-sm text-base-content/85` |
|
||||
| Fine print | small hints | `text-xs text-base-content/80` |
|
||||
| Empty state | no data | `text-base-content/80 italic` |
|
||||
| Destructive text | danger | `text-error` |
|
||||
|
||||
**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).
|
||||
|
||||
**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 de‑emphasised 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)
|
||||
|
|
@ -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).
|
||||
- **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)
|
||||
|
|
|
|||
1
Justfile
1
Justfile
|
|
@ -10,6 +10,7 @@ install-dependencies:
|
|||
mix deps.get
|
||||
|
||||
migrate-database:
|
||||
mix compile
|
||||
mix ash.setup
|
||||
|
||||
reset-database:
|
||||
|
|
|
|||
|
|
@ -154,6 +154,14 @@
|
|||
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
|
||||
de‑emphasised 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.
|
||||
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
|
||||
|
|
|
|||
|
|
@ -25,6 +25,14 @@ import Sortable from "../vendor/sortable"
|
|||
|
||||
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
|
||||
let Hooks = {}
|
||||
|
||||
|
|
@ -312,7 +320,10 @@ Hooks.SidebarState = {
|
|||
|
||||
let liveSocket = new LiveSocket("/live", Socket, {
|
||||
longPollFallbackMs: 2500,
|
||||
params: {_csrf_token: csrfToken},
|
||||
params: {
|
||||
_csrf_token: csrfToken,
|
||||
timezone: getBrowserTimezone()
|
||||
},
|
||||
hooks: Hooks
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
ecto_repos: [Mv.Repo],
|
||||
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.
|
||||
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)
|
||||
config :esbuild,
|
||||
version: "0.17.11",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -806,7 +806,7 @@ end
|
|||
- **Senders migrated:** `SendNewUserConfirmationEmail`, `SendPasswordResetEmail` use layout + `Mv.Mailer.mail_from/0`.
|
||||
- **Cleanup:** Mix task `mix join_requests.cleanup_expired` hard-deletes JoinRequests in `pending_confirmation` with expired `confirmation_token_expires_at` (authorize?: false). For cron/Oban.
|
||||
- **Gettext:** New email strings in default domain; German translations in de/LC_MESSAGES/default.po; English msgstr filled for email-related strings.
|
||||
- **PR review follow-ups (Join confirmation):** Join confirmation email uses `Mailer.deliver/1` and returns `{:ok, email}` \| `{:error, reason}`; domain logs delivery errors but still returns `{:ok, request}` so the user sees success. Comment in `submit_join_request/2` clarifies that the raw token is hashed by `JoinRequest.Changes.SetConfirmationToken`. Cleanup task uses `Ash.bulk_destroy` and logs partial errors without halting. Layout uses assigns `app_name` and `locale` (from config/Gettext) instead of hardcoded "Mila" and `lang="de"`. Production `runtime.exs` sets `:mail_from` from ENV (`MAIL_FROM_NAME`, `MAIL_FROM_EMAIL`). Layout reference unified to `"layout.html"`; redundant `put_layout` removed from senders.
|
||||
- **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.
|
||||
|
||||
**Subtask 3 – Admin: Join form settings (done):**
|
||||
|
|
|
|||
|
|
@ -36,10 +36,10 @@
|
|||
|
||||
**Closed Issues:**
|
||||
- ✅ [#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:**
|
||||
- [#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)
|
||||
**Open Issues:** (none remaining for Authentication UI)
|
||||
|
||||
**Current State:**
|
||||
- ✅ **Role-based access control (RBAC)** - Implemented (2026-01-08, PR #346, closes #345)
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@
|
|||
|
||||
- **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.
|
||||
- **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**.
|
||||
- **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.
|
||||
|
|
@ -115,7 +116,7 @@ Implementation spec for Subtask 5.
|
|||
#### 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.
|
||||
- **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)
|
||||
|
||||
|
|
@ -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).
|
||||
- **Confirmation route:** **`/confirm_join/:token`** so existing `starts_with?(path, "/confirm")` covers it.
|
||||
- **Public path for `/join`:** **Add `/join` explicitly** to the page-permission plug’s `public_path?/1` (e.g. in `CheckPagePermission`) so unauthenticated users can reach the join page.
|
||||
- **JoinRequest schema:** Status `pending_confirmation` | `submitted` | `approved` | `rejected`. Typed: **email** (required), **first_name**, **last_name** (optional). **form_data** (jsonb) + **schema_version** for remaining form fields. **confirmation_token_hash**, **confirmation_token_expires_at**; **submitted_at**, **approved_at**, **rejected_at**, **reviewed_by_user_id** for audit. Idempotent confirm (unique constraint on token hash or update only when status is `pending_confirmation`).
|
||||
- **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**.
|
||||
- **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**.
|
||||
|
|
|
|||
44
docs/settings-authentication-mockup.txt
Normal file
44
docs/settings-authentication-mockup.txt
Normal 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] |
|
||||
+------------------------------------------------------------------+
|
||||
|
|
@ -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.
|
||||
|
||||
**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
|
||||
|
|
@ -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`.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
|
|
@ -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: `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] 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] 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] Gettext for all new UI strings, translated to German.
|
||||
- [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.
|
||||
- **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.
|
||||
|
|
|
|||
|
|
@ -405,6 +405,10 @@ defmodule Mv.Accounts.User do
|
|||
where: [action_is([:register_with_password, :admin_set_password])],
|
||||
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
|
||||
# Validates that user email is not already used by another (unlinked) member
|
||||
validate Mv.Accounts.User.Validations.EmailNotUsedByOtherMember
|
||||
|
|
|
|||
31
lib/accounts/user/validations/registration_enabled.ex
Normal file
31
lib/accounts/user/validations/registration_enabled.ex
Normal 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
|
||||
13
lib/membership/join_notifier.ex
Normal file
13
lib/membership/join_notifier.ex
Normal 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
|
||||
|
|
@ -77,6 +77,17 @@ defmodule Mv.Membership.JoinRequest do
|
|||
|
||||
change Mv.Membership.JoinRequest.Changes.RejectRequest
|
||||
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
|
||||
|
||||
policies do
|
||||
|
|
@ -175,6 +186,11 @@ defmodule Mv.Membership.JoinRequest do
|
|||
attribute :approved_at, :utc_datetime_usec
|
||||
attribute :rejected_at, :utc_datetime_usec
|
||||
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
|
||||
|
||||
create_timestamp :inserted_at
|
||||
|
|
|
|||
|
|
@ -16,11 +16,13 @@ defmodule Mv.Membership.JoinRequest.Changes.ApproveRequest do
|
|||
|
||||
if current_status == :submitted do
|
||||
reviewed_by_id = Helpers.actor_id(context.actor)
|
||||
reviewed_by_display = Helpers.actor_email(context.actor)
|
||||
|
||||
changeset
|
||||
|> Ash.Changeset.force_change_attribute(:status, :approved)
|
||||
|> 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_display, reviewed_by_display)
|
||||
else
|
||||
Ash.Changeset.add_error(changeset,
|
||||
field: :status,
|
||||
|
|
|
|||
|
|
@ -16,4 +16,24 @@ defmodule Mv.Membership.JoinRequest.Changes.Helpers do
|
|||
end
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -15,11 +15,13 @@ defmodule Mv.Membership.JoinRequest.Changes.RejectRequest do
|
|||
|
||||
if current_status == :submitted do
|
||||
reviewed_by_id = Helpers.actor_id(context.actor)
|
||||
reviewed_by_display = Helpers.actor_email(context.actor)
|
||||
|
||||
changeset
|
||||
|> Ash.Changeset.force_change_attribute(:status, :rejected)
|
||||
|> 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_display, reviewed_by_display)
|
||||
else
|
||||
Ash.Changeset.add_error(changeset,
|
||||
field: :status,
|
||||
|
|
|
|||
|
|
@ -29,8 +29,10 @@ defmodule Mv.Membership do
|
|||
require Ash.Query
|
||||
import Ash.Expr
|
||||
alias Ash.Error.Query.NotFound, as: NotFoundError
|
||||
alias Mv.Helpers.SystemActor
|
||||
alias Mv.Membership.JoinRequest
|
||||
alias MvWeb.Emails.JoinConfirmationEmail
|
||||
alias Mv.Membership.Member
|
||||
alias Mv.Membership.SettingsCache
|
||||
require Logger
|
||||
|
||||
admin do
|
||||
|
|
@ -114,10 +116,16 @@ defmodule Mv.Membership 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
|
||||
{: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"
|
||||
|
||||
Mv.Membership.Setting
|
||||
|
|
@ -158,9 +166,16 @@ defmodule Mv.Membership do
|
|||
|
||||
"""
|
||||
def update_settings(settings, attrs) do
|
||||
settings
|
||||
|> Ash.Changeset.for_update(:update, attrs)
|
||||
|> Ash.update(domain: __MODULE__)
|
||||
case settings
|
||||
|> Ash.Changeset.for_update(:update, attrs)
|
||||
|> Ash.update(domain: __MODULE__) do
|
||||
{:ok, _updated} = result ->
|
||||
SettingsCache.invalidate()
|
||||
result
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
|
@ -224,11 +239,18 @@ defmodule Mv.Membership do
|
|||
|
||||
"""
|
||||
def update_member_field_visibility(settings, visibility_config) do
|
||||
settings
|
||||
|> Ash.Changeset.for_update(:update_member_field_visibility, %{
|
||||
member_field_visibility: visibility_config
|
||||
})
|
||||
|> Ash.update(domain: __MODULE__)
|
||||
case settings
|
||||
|> Ash.Changeset.for_update(:update_member_field_visibility, %{
|
||||
member_field_visibility: visibility_config
|
||||
})
|
||||
|> Ash.update(domain: __MODULE__) do
|
||||
{:ok, _} = result ->
|
||||
SettingsCache.invalidate()
|
||||
result
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
|
@ -261,12 +283,19 @@ defmodule Mv.Membership do
|
|||
field: field,
|
||||
show_in_overview: show_in_overview
|
||||
) do
|
||||
settings
|
||||
|> Ash.Changeset.new()
|
||||
|> Ash.Changeset.set_argument(:field, field)
|
||||
|> Ash.Changeset.set_argument(:show_in_overview, show_in_overview)
|
||||
|> Ash.Changeset.for_update(:update_single_member_field_visibility, %{})
|
||||
|> Ash.update(domain: __MODULE__)
|
||||
case settings
|
||||
|> Ash.Changeset.new()
|
||||
|> Ash.Changeset.set_argument(:field, field)
|
||||
|> Ash.Changeset.set_argument(:show_in_overview, show_in_overview)
|
||||
|> Ash.Changeset.for_update(:update_single_member_field_visibility, %{})
|
||||
|> Ash.update(domain: __MODULE__) do
|
||||
{:ok, _} = result ->
|
||||
SettingsCache.invalidate()
|
||||
result
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
|
@ -300,13 +329,20 @@ defmodule Mv.Membership do
|
|||
show_in_overview: show_in_overview,
|
||||
required: required
|
||||
) do
|
||||
settings
|
||||
|> Ash.Changeset.new()
|
||||
|> Ash.Changeset.set_argument(:field, field)
|
||||
|> Ash.Changeset.set_argument(:show_in_overview, show_in_overview)
|
||||
|> Ash.Changeset.set_argument(:required, required)
|
||||
|> Ash.Changeset.for_update(:update_single_member_field, %{})
|
||||
|> Ash.update(domain: __MODULE__)
|
||||
case settings
|
||||
|> Ash.Changeset.new()
|
||||
|> Ash.Changeset.set_argument(:field, field)
|
||||
|> Ash.Changeset.set_argument(:show_in_overview, show_in_overview)
|
||||
|> Ash.Changeset.set_argument(:required, required)
|
||||
|> Ash.Changeset.for_update(:update_single_member_field, %{})
|
||||
|> Ash.update(domain: __MODULE__) do
|
||||
{:ok, _} = result ->
|
||||
SettingsCache.invalidate()
|
||||
result
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
|
@ -364,15 +400,131 @@ defmodule Mv.Membership do
|
|||
- `:actor` - Must be nil for public submit (policy allows only unauthenticated).
|
||||
|
||||
## 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
|
||||
"""
|
||||
def submit_join_request(attrs, opts \\ []) do
|
||||
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
|
||||
# hashes it before persist. Only the hash is stored; the raw token is sent in the email link.
|
||||
pending =
|
||||
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)
|
||||
|
||||
case Ash.create(JoinRequest, attrs_with_token,
|
||||
|
|
@ -381,8 +533,9 @@ defmodule Mv.Membership do
|
|||
domain: __MODULE__
|
||||
) do
|
||||
{:ok, request} ->
|
||||
case JoinConfirmationEmail.send(request.email, token) do
|
||||
case join_notifier().send_confirmation(request.email, token, []) do
|
||||
{:ok, _email} ->
|
||||
# Delay is applied by the caller (e.g. JoinLive) to avoid blocking the process.
|
||||
{:ok, request}
|
||||
|
||||
{:error, reason} ->
|
||||
|
|
@ -390,8 +543,7 @@ defmodule Mv.Membership do
|
|||
"Join confirmation email failed for #{request.email}: #{inspect(reason)}"
|
||||
)
|
||||
|
||||
# Request was created; return success so the user sees the confirmation message
|
||||
{:ok, request}
|
||||
{:error, :email_delivery_failed}
|
||||
end
|
||||
|
||||
error ->
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
- `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)
|
||||
- `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_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
|
||||
|
|
@ -129,6 +130,7 @@ defmodule Mv.Membership.Setting do
|
|||
:smtp_ssl,
|
||||
:smtp_from_name,
|
||||
:smtp_from_email,
|
||||
:registration_enabled,
|
||||
:join_form_enabled,
|
||||
:join_form_field_ids,
|
||||
:join_form_field_required
|
||||
|
|
@ -165,6 +167,7 @@ defmodule Mv.Membership.Setting do
|
|||
:smtp_ssl,
|
||||
:smtp_from_name,
|
||||
:smtp_from_email,
|
||||
:registration_enabled,
|
||||
:join_form_enabled,
|
||||
:join_form_field_ids,
|
||||
: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."
|
||||
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
|
||||
attribute :join_form_enabled, :boolean do
|
||||
allow_nil? false
|
||||
|
|
|
|||
85
lib/membership/settings_cache.ex
Normal file
85
lib/membership/settings_cache.ex
Normal 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
|
||||
|
|
@ -6,6 +6,7 @@ defmodule Mv.Application do
|
|||
use Application
|
||||
|
||||
alias Mv.Helpers.SystemActor
|
||||
alias Mv.Membership.SettingsCache
|
||||
alias Mv.Repo
|
||||
alias Mv.Vereinfacht.SyncFlash
|
||||
alias MvWeb.Endpoint
|
||||
|
|
@ -16,20 +17,28 @@ defmodule Mv.Application do
|
|||
def start(_type, _args) do
|
||||
SyncFlash.create_table!()
|
||||
|
||||
children = [
|
||||
Telemetry,
|
||||
Repo,
|
||||
{JoinRateLimit, [clean_period: :timer.minutes(1)]},
|
||||
{Task.Supervisor, name: Mv.TaskSupervisor},
|
||||
{DNSCluster, query: Application.get_env(:mv, :dns_cluster_query) || :ignore},
|
||||
{Phoenix.PubSub, name: Mv.PubSub},
|
||||
{AshAuthentication.Supervisor, otp_app: :my},
|
||||
SystemActor,
|
||||
# Start a worker by calling: Mv.Worker.start_link(arg)
|
||||
# {Mv.Worker, arg},
|
||||
# Start to serve requests, typically the last entry
|
||||
Endpoint
|
||||
]
|
||||
# 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,
|
||||
Repo
|
||||
] ++
|
||||
cache_children ++
|
||||
[
|
||||
{JoinRateLimit, [clean_period: :timer.minutes(1)]},
|
||||
{Task.Supervisor, name: Mv.TaskSupervisor},
|
||||
{DNSCluster, query: Application.get_env(:mv, :dns_cluster_query) || :ignore},
|
||||
{Phoenix.PubSub, name: Mv.PubSub},
|
||||
{AshAuthentication.Supervisor, otp_app: :my},
|
||||
SystemActor,
|
||||
# Start a worker by calling: Mv.Worker.start_link(arg)
|
||||
# {Mv.Worker, arg},
|
||||
# Start to serve requests, typically the last entry
|
||||
Endpoint
|
||||
]
|
||||
|
||||
# See https://hexdocs.pm/elixir/Supervisor.html
|
||||
# for other strategies and supported options
|
||||
|
|
|
|||
|
|
@ -3,52 +3,70 @@ defmodule MvWeb.AuthOverrides do
|
|||
UI customizations for AshAuthentication Phoenix components.
|
||||
|
||||
## Overrides
|
||||
- `SignIn` - Restricts form width to prevent full-width display
|
||||
- `Banner` - Replaces default logo with "Mitgliederverwaltung" text
|
||||
- `HorizontalRule` - Translates "or" text to German
|
||||
- `SignIn` - Restricts form width and hides the library banner (title is rendered in SignInLive)
|
||||
- `Banner` - Replaces default logo with text for reset/confirm pages
|
||||
- `Flash` - Hides library flash (we use flash_group in root layout)
|
||||
|
||||
## Documentation
|
||||
For complete reference on available overrides, see:
|
||||
https://hexdocs.pm/ash_authentication_phoenix/ui-overrides.html
|
||||
"""
|
||||
use AshAuthentication.Phoenix.Overrides
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
# configure your UI overrides here
|
||||
|
||||
# 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
|
||||
# Avoid full-width for the Sign In Form.
|
||||
# Banner is hidden because SignInLive renders its own locale-aware title.
|
||||
override AshAuthentication.Phoenix.Components.SignIn do
|
||||
set :root_class, "md:min-w-md"
|
||||
set :show_banner, false
|
||||
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
|
||||
set :text, "Mitgliederverwaltung"
|
||||
set :image_url, nil
|
||||
set :dark_image_url, nil
|
||||
end
|
||||
|
||||
# Translate the "or" in the horizontal rule (between password form and SSO).
|
||||
# Uses auth domain so it respects the current locale (e.g. "oder" in German).
|
||||
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
|
||||
# Hide AshAuthentication's Flash component since we use flash_group in root layout.
|
||||
# This prevents duplicate flash messages.
|
||||
override AshAuthentication.Phoenix.Components.Flash do
|
||||
set :message_class_info, "hidden"
|
||||
set :message_class_error, "hidden"
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1295,6 +1295,41 @@ defmodule MvWeb.CoreComponents do
|
|||
"""
|
||||
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 """
|
||||
Renders a [Heroicon](https://heroicons.com).
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,98 @@ defmodule MvWeb.Layouts do
|
|||
|
||||
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 """
|
||||
Renders the app layout. Can be used with or without a current_user.
|
||||
When current_user is present, it will show the navigation bar.
|
||||
|
|
@ -99,24 +191,30 @@ defmodule MvWeb.Layouts do
|
|||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<!-- Unauthenticated: simple header (logo, club name, language selector; same classes as sidebar header) -->
|
||||
<header class="flex items-center gap-3 p-4 border-b border-base-300 bg-base-100">
|
||||
<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">
|
||||
<!-- Unauthenticated: Option 3 header (logo + app name left, club name center, language selector right) -->
|
||||
<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="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}
|
||||
</span>
|
||||
<form method="post" action={~p"/set_locale"} class="shrink-0">
|
||||
<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() == "de"}>Deutsch</option>
|
||||
<option value="en" selected={Gettext.get_locale() == "en"}>English</option>
|
||||
</select>
|
||||
</form>
|
||||
<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 space-y-4 max-full">
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="csrf-token" content={get_csrf_token()} />
|
||||
<link phx-track-static rel="icon" type="image/svg+xml" href={~p"/images/mila.svg"} />
|
||||
<.live_title default="Mv" suffix=" · Phoenix Framework">
|
||||
{assigns[:page_title]}
|
||||
<.live_title default="Mila">
|
||||
{page_title_string(assigns)}
|
||||
</.live_title>
|
||||
<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"}>
|
||||
|
|
|
|||
|
|
@ -251,21 +251,22 @@ defmodule MvWeb.Layouts.Sidebar do
|
|||
defp sidebar_footer(assigns) do
|
||||
~H"""
|
||||
<div class="mt-auto p-4 border-t border-base-300 space-y-4">
|
||||
<!-- Language Selector (nur expanded) -->
|
||||
<form method="post" action={~p"/set_locale"} class="expanded-only">
|
||||
<input type="hidden" name="_csrf_token" value={get_csrf_token()} />
|
||||
<select
|
||||
name="locale"
|
||||
onchange="this.form.submit()"
|
||||
class="select select-sm w-full 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() == "de"}>Deutsch</option>
|
||||
<option value="en" selected={Gettext.get_locale() == "en"}>English</option>
|
||||
</select>
|
||||
</form>
|
||||
<!-- Theme Toggle (immer sichtbar) -->
|
||||
<.theme_toggle />
|
||||
<!-- Theme swap + Language selector in one row (theme left, language right when expanded) -->
|
||||
<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()} />
|
||||
<select
|
||||
name="locale"
|
||||
onchange="this.form.submit()"
|
||||
class="select select-sm w-full 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>
|
||||
</div>
|
||||
<!-- User Menu (nur wenn current_user existiert) -->
|
||||
<%= if @current_user do %>
|
||||
<.user_menu current_user={@current_user} />
|
||||
|
|
@ -274,29 +275,6 @@ defmodule MvWeb.Layouts.Sidebar do
|
|||
"""
|
||||
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"
|
||||
|
||||
defp user_menu(assigns) do
|
||||
|
|
|
|||
|
|
@ -2,11 +2,14 @@ defmodule MvWeb.JoinConfirmController do
|
|||
@moduledoc """
|
||||
Handles GET /confirm_join/:token for the public join flow (double opt-in).
|
||||
|
||||
Calls a configurable callback (default Mv.Membership) so tests can stub the
|
||||
dependency. Public route; no authentication required.
|
||||
Renders a full HTML page with public header and hero layout (success, expired,
|
||||
or invalid). Calls a configurable callback (default Mv.Membership) so tests can
|
||||
stub the dependency. Public route; no authentication required.
|
||||
"""
|
||||
use MvWeb, :controller
|
||||
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
def confirm(conn, %{"token" => token}) when is_binary(token) do
|
||||
callback = Application.get_env(:mv, :join_confirm_callback, Mv.Membership)
|
||||
|
||||
|
|
@ -26,20 +29,36 @@ defmodule MvWeb.JoinConfirmController do
|
|||
|
||||
defp success_response(conn) do
|
||||
conn
|
||||
|> put_resp_content_type("text/html")
|
||||
|> send_resp(200, gettext("Thank you, we have received your request."))
|
||||
|> assign_confirm_assigns(:success)
|
||||
|> put_view(MvWeb.JoinConfirmHTML)
|
||||
|> render("confirm.html")
|
||||
end
|
||||
|
||||
defp expired_response(conn) do
|
||||
conn
|
||||
|> put_resp_content_type("text/html")
|
||||
|> send_resp(200, gettext("This link has expired. Please submit the form again."))
|
||||
|> assign_confirm_assigns(:expired)
|
||||
|> put_view(MvWeb.JoinConfirmHTML)
|
||||
|> render("confirm.html")
|
||||
end
|
||||
|
||||
defp invalid_response(conn) do
|
||||
conn
|
||||
|> put_resp_content_type("text/html")
|
||||
|> put_status(404)
|
||||
|> send_resp(404, gettext("Invalid or expired link."))
|
||||
|> assign_confirm_assigns(:invalid)
|
||||
|> put_view(MvWeb.JoinConfirmHTML)
|
||||
|> render("confirm.html")
|
||||
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
|
||||
|
|
|
|||
9
lib/mv_web/controllers/join_confirm_html.ex
Normal file
9
lib/mv_web/controllers/join_confirm_html.ex
Normal 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
|
||||
45
lib/mv_web/controllers/join_confirm_html/confirm.html.heex
Normal file
45
lib/mv_web/controllers/join_confirm_html/confirm.html.heex
Normal 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>
|
||||
|
|
@ -7,7 +7,11 @@ defmodule MvWeb.PageController do
|
|||
"""
|
||||
use MvWeb, :controller
|
||||
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
def home(conn, _params) do
|
||||
render(conn, :home)
|
||||
conn
|
||||
|> assign(:page_title, gettext("Home"))
|
||||
|> render(:home)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
42
lib/mv_web/emails/join_already_member_email.ex
Normal file
42
lib/mv_web/emails/join_already_member_email.ex
Normal 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
|
||||
43
lib/mv_web/emails/join_already_pending_email.ex
Normal file
43
lib/mv_web/emails/join_already_pending_email.ex
Normal 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
|
||||
|
|
@ -15,13 +15,19 @@ defmodule MvWeb.Emails.JoinConfirmationEmail do
|
|||
@doc """
|
||||
Sends the join confirmation email to the given address with the confirmation link.
|
||||
|
||||
Called from the domain after a JoinRequest is created (submit flow).
|
||||
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.
|
||||
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}")
|
||||
subject = gettext("Confirm your membership request")
|
||||
|
||||
|
|
@ -29,15 +35,18 @@ defmodule MvWeb.Emails.JoinConfirmationEmail do
|
|||
confirm_url: confirm_url,
|
||||
subject: subject,
|
||||
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)
|
||||
}
|
||||
|
||||
new()
|
||||
|> from(Mailer.mail_from())
|
||||
|> to(email_address)
|
||||
|> subject(subject)
|
||||
|> put_view(MvWeb.EmailsView)
|
||||
|> render_body("join_confirmation.html", assigns)
|
||||
|> Mailer.deliver()
|
||||
email =
|
||||
new()
|
||||
|> from(Mailer.mail_from())
|
||||
|> to(email_address)
|
||||
|> subject(subject)
|
||||
|> put_view(MvWeb.EmailsView)
|
||||
|> render_body("join_confirmation.html", assigns)
|
||||
|
||||
Mailer.deliver(email, Mailer.smtp_config())
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ defmodule MvWeb.Helpers.DateFormatter do
|
|||
@moduledoc """
|
||||
Centralized date formatting helper for the application.
|
||||
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
|
||||
|
|
@ -28,19 +29,40 @@ defmodule MvWeb.Helpers.DateFormatter do
|
|||
@doc """
|
||||
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
|
||||
|
||||
iex> MvWeb.Helpers.DateFormatter.format_datetime(~U[2024-03-15 10:30:00Z])
|
||||
"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)
|
||||
""
|
||||
"""
|
||||
def format_datetime(%DateTime{} = dt) do
|
||||
Calendar.strftime(dt, "%d.%m.%Y %H:%M")
|
||||
def format_datetime(%DateTime{} = dt), do: format_datetime(dt, nil)
|
||||
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
|
||||
|
||||
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
|
||||
|
|
|
|||
25
lib/mv_web/join_notifier_impl.ex
Normal file
25
lib/mv_web/join_notifier_impl.ex
Normal 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
|
||||
|
|
@ -1,28 +1,61 @@
|
|||
defmodule MvWeb.SignInLive do
|
||||
@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).
|
||||
- Wraps the default AshAuthentication SignIn component in a container with
|
||||
`data-oidc-configured` so that CSS can hide the SSO button when OIDC is not configured.
|
||||
Uses Layouts.public_page (no sidebar, no app-layout hooks). Wraps the AshAuthentication
|
||||
SignIn component in a hero section. Container has data-oidc-configured so CSS can hide
|
||||
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 Gettext, backend: MvWeb.Gettext
|
||||
|
||||
alias AshAuthentication.Phoenix.Components
|
||||
alias Mv.Config
|
||||
alias Mv.Membership
|
||||
alias MvWeb.{AuthOverridesDE, AuthOverridesRegistrationDisabled, Layouts}
|
||||
|
||||
@impl true
|
||||
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 =
|
||||
session["locale"] || Application.get_env(:mv, :default_locale, "de")
|
||||
locale = 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(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
|
||||
|
|
@ -30,18 +63,19 @@ defmodule MvWeb.SignInLive do
|
|||
|> assign_new(:otp_app, fn -> nil end)
|
||||
|> assign(:path, session["path"] || "/")
|
||||
|> assign(:reset_path, session["reset_path"])
|
||||
|> assign(:register_path, session["register_path"])
|
||||
|> assign(:register_path, register_path)
|
||||
|> assign(:current_tenant, session["tenant"])
|
||||
|> assign(:resources, session["resources"])
|
||||
|> assign(:context, session["context"] || %{})
|
||||
|> assign(:auth_routes_prefix, session["auth_routes_prefix"])
|
||||
|> 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_only, Config.oidc_only?())
|
||||
|> assign(:root_class, "grid h-screen place-items-center bg-base-100")
|
||||
|> assign(:sign_in_id, "sign-in")
|
||||
|> assign(:locale, locale)
|
||||
|> assign(:club_name, club_name)
|
||||
|> Layouts.assign_page_title(gettext("Sign in"))
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
|
@ -54,50 +88,43 @@ defmodule MvWeb.SignInLive do
|
|||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<main
|
||||
id="sign-in-page"
|
||||
role="main"
|
||||
class={@root_class}
|
||||
data-oidc-configured={to_string(@oidc_configured)}
|
||||
data-oidc-only={to_string(@oidc_only)}
|
||||
data-locale={@locale}
|
||||
>
|
||||
<h1 class="sr-only">{dgettext("auth", "Sign in")}</h1>
|
||||
<%!-- Language selector --%>
|
||||
<nav
|
||||
aria-label={dgettext("auth", "Language selection")}
|
||||
class="absolute top-4 right-4 flex justify-end z-10"
|
||||
>
|
||||
<form method="post" action="/set_locale" class="text-sm">
|
||||
<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
|
||||
module={Components.SignIn}
|
||||
otp_app={@otp_app}
|
||||
live_action={@live_action}
|
||||
path={@path}
|
||||
auth_routes_prefix={@auth_routes_prefix}
|
||||
resources={@resources}
|
||||
reset_path={@reset_path}
|
||||
register_path={@register_path}
|
||||
id={@sign_in_id}
|
||||
overrides={@overrides}
|
||||
current_tenant={@current_tenant}
|
||||
context={@context}
|
||||
gettext_fn={@gettext_fn}
|
||||
/>
|
||||
</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"
|
||||
role="main"
|
||||
data-oidc-configured={to_string(@oidc_configured)}
|
||||
data-oidc-only={to_string(@oidc_only)}
|
||||
data-locale={@locale}
|
||||
>
|
||||
<div class="hero-content flex-col items-start text-left">
|
||||
<div class="w-full max-w-md">
|
||||
<h1 class="text-xl font-semibold leading-8">
|
||||
{if @live_action == :register,
|
||||
do: dgettext("auth", "Register"),
|
||||
else: dgettext("auth", "Sign in")}
|
||||
</h1>
|
||||
<.live_component
|
||||
module={Components.SignIn}
|
||||
otp_app={@otp_app}
|
||||
live_action={@live_action}
|
||||
path={@path}
|
||||
auth_routes_prefix={@auth_routes_prefix}
|
||||
resources={@resources}
|
||||
reset_path={@reset_path}
|
||||
register_path={@register_path}
|
||||
id={@sign_in_id}
|
||||
overrides={@overrides}
|
||||
current_tenant={@current_tenant}
|
||||
context={@context}
|
||||
gettext_fn={@gettext_fn}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layouts.public_page>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ defmodule MvWeb.DatafieldsLive do
|
|||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, gettext("Datafields"))
|
||||
|> Layouts.assign_page_title(gettext("Datafields"))
|
||||
|> assign(:settings, settings)
|
||||
|> assign(:active_editing_section, nil)
|
||||
|> assign(:custom_field_delete_modal_open, false)}
|
||||
|
|
@ -50,7 +50,7 @@ defmodule MvWeb.DatafieldsLive do
|
|||
~H"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user} club_name={@settings.club_name}>
|
||||
<.header>
|
||||
{gettext("Datafields")}
|
||||
{@content_title}
|
||||
<:subtitle>
|
||||
{gettext(
|
||||
"Configure which data you want to save for your members. Define individual datafields."
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -32,7 +32,7 @@ defmodule MvWeb.GroupLive.Form do
|
|||
socket
|
||||
|> assign(:actor, actor)
|
||||
|> 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))}
|
||||
else
|
||||
{:ok, redirect(socket, to: ~p"/groups")}
|
||||
|
|
@ -56,7 +56,7 @@ defmodule MvWeb.GroupLive.Form do
|
|||
{:noreply,
|
||||
socket
|
||||
|> assign(:group, group)
|
||||
|> assign(:page_title, gettext("Edit Group"))
|
||||
|> Layouts.assign_page_title(gettext("Edit Group"))
|
||||
|> assign(:return_to, :show)
|
||||
|> assign_form(actor)}
|
||||
|
||||
|
|
@ -85,7 +85,7 @@ defmodule MvWeb.GroupLive.Form do
|
|||
{gettext("Back")}
|
||||
</.button>
|
||||
</:leading>
|
||||
{@page_title}
|
||||
{@content_title}
|
||||
<:actions>
|
||||
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
|
||||
{gettext("Save")}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ defmodule MvWeb.GroupLive.Index do
|
|||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, gettext("Groups"))
|
||||
|> Layouts.assign_page_title(gettext("Groups"))
|
||||
|> assign(:groups, groups)}
|
||||
else
|
||||
{:ok, redirect(socket, to: ~p"/members")}
|
||||
|
|
@ -40,7 +40,7 @@ defmodule MvWeb.GroupLive.Index do
|
|||
~H"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
{gettext("Groups")}
|
||||
{@content_title}
|
||||
<:actions>
|
||||
<%= if can?(@current_user, :create, Mv.Membership.Group) do %>
|
||||
<.button navigate={~p"/groups/new"} variant="primary">
|
||||
|
|
|
|||
|
|
@ -70,9 +70,11 @@ defmodule MvWeb.GroupLive.Show do
|
|||
{:ok, group} ->
|
||||
open_delete = params["confirm_delete"] == "1" && can?(actor, :destroy, group)
|
||||
|
||||
content_title = gettext("Group %{name}", name: group.name)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:page_title, group.name)
|
||||
|> Layouts.assign_page_title(content_title)
|
||||
|> assign(:group, group)
|
||||
|> assign(:show_delete_modal, open_delete)
|
||||
|> assign(:name_confirmation, "")
|
||||
|
|
@ -102,7 +104,7 @@ defmodule MvWeb.GroupLive.Show do
|
|||
{gettext("Back")}
|
||||
</.button>
|
||||
</:leading>
|
||||
{@group.name}
|
||||
{@content_title}
|
||||
<:actions>
|
||||
<%= if can?(@current_user, :update, @group) do %>
|
||||
<.button
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ defmodule MvWeb.ImportLive do
|
|||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:page_title, gettext("Import"))
|
||||
|> Layouts.assign_page_title(gettext("Import"))
|
||||
|> assign(:club_name, club_name)
|
||||
|> assign(:import_state, nil)
|
||||
|> assign(:import_progress, nil)
|
||||
|
|
@ -94,7 +94,7 @@ defmodule MvWeb.ImportLive do
|
|||
<%!-- CSV Import Section --%>
|
||||
<div data-testid="import-page">
|
||||
<.header>
|
||||
{gettext("Import Members")}
|
||||
{@content_title}
|
||||
<:subtitle>
|
||||
{gettext("Import members from CSV files.")}
|
||||
</:subtitle>
|
||||
|
|
|
|||
|
|
@ -12,12 +12,22 @@ defmodule MvWeb.JoinLive do
|
|||
# Honeypot field name (legitimate-sounding to avoid bot detection)
|
||||
@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
|
||||
def mount(_params, _session, socket) do
|
||||
allowlist = Membership.get_join_form_allowlist()
|
||||
join_fields = build_join_fields_with_labels(allowlist)
|
||||
client_ip = client_ip_from_socket(socket)
|
||||
|
||||
club_name =
|
||||
case Membership.get_settings() do
|
||||
{:ok, s} -> s.club_name || "Mitgliederverwaltung"
|
||||
_ -> "Mitgliederverwaltung"
|
||||
end
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:join_fields, join_fields)
|
||||
|
|
@ -25,6 +35,8 @@ defmodule MvWeb.JoinLive do
|
|||
|> assign(:rate_limit_error, nil)
|
||||
|> assign(:client_ip, client_ip)
|
||||
|> 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)))
|
||||
|
||||
{:ok, socket}
|
||||
|
|
@ -33,91 +45,97 @@ defmodule MvWeb.JoinLive do
|
|||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<div class="max-w-xl mx-auto mt-8 space-y-6">
|
||||
<.header>
|
||||
{gettext("Become a member")}
|
||||
</.header>
|
||||
<Layouts.public_page flash={@flash} club_name={@club_name}>
|
||||
<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>
|
||||
{gettext("Become a member")}
|
||||
</.header>
|
||||
|
||||
<p class="text-base-content/80">
|
||||
{gettext("Please enter your details for the membership application here.")}
|
||||
</p>
|
||||
<p class="text-base-content/80">
|
||||
{gettext("Please enter your details for the membership application here.")}
|
||||
</p>
|
||||
|
||||
<%= if @submitted do %>
|
||||
<div data-testid="join-success-message" class="alert alert-success">
|
||||
<p class="font-medium">
|
||||
{gettext(
|
||||
"We have saved your details. To complete your request, please click the link we sent to your email."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<% else %>
|
||||
<.form
|
||||
for={@form}
|
||||
id="join-form"
|
||||
phx-submit="submit"
|
||||
class="space-y-4"
|
||||
>
|
||||
<%= if @rate_limit_error do %>
|
||||
<div class="alert alert-error">
|
||||
{@rate_limit_error}
|
||||
</div>
|
||||
<% end %>
|
||||
<%= if @submitted do %>
|
||||
<div data-testid="join-success-message" class="alert alert-success">
|
||||
<p class="font-medium">
|
||||
{gettext(
|
||||
"We have saved your details. To complete your request, please click the link we sent to your email."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<% else %>
|
||||
<.form
|
||||
for={@form}
|
||||
id="join-form"
|
||||
phx-submit="submit"
|
||||
class="space-y-4"
|
||||
>
|
||||
<%= if @rate_limit_error do %>
|
||||
<div class="alert alert-error">
|
||||
{@rate_limit_error}
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= for field <- @join_fields do %>
|
||||
<div>
|
||||
<label for={"join-field-#{field.id}"} class="label">
|
||||
<span class="label-text">{field.label}{if field.required, do: " *"}</span>
|
||||
</label>
|
||||
<input
|
||||
type={input_type(field.id)}
|
||||
name={field.id}
|
||||
id={"join-field-#{field.id}"}
|
||||
value={@form.params[field.id]}
|
||||
required={field.required}
|
||||
class="input input-bordered w-full"
|
||||
/>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= for field <- @join_fields do %>
|
||||
<div>
|
||||
<label for={"join-field-#{field.id}"} class="label">
|
||||
<span class="label-text">{field.label}{if field.required, do: " *"}</span>
|
||||
</label>
|
||||
<input
|
||||
type={input_type(field.id)}
|
||||
name={field.id}
|
||||
id={"join-field-#{field.id}"}
|
||||
value={@form.params[field.id]}
|
||||
required={field.required}
|
||||
class="input input-bordered w-full"
|
||||
/>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%!--
|
||||
<%!--
|
||||
Honeypot (best practice): legit field name "website", type="text", no inline CSS,
|
||||
hidden via class in app.css (off-screen + 1px). tabindex=-1, autocomplete=off,
|
||||
aria-hidden so screen readers skip. If filled → silent failure (same success UI).
|
||||
--%>
|
||||
<div class="join-form-helper" aria-hidden="true">
|
||||
<label for="join-website" class="sr-only">{gettext("Website")}</label>
|
||||
<input
|
||||
type="text"
|
||||
name={@honeypot_field}
|
||||
id="join-website"
|
||||
tabindex="-1"
|
||||
autocomplete="off"
|
||||
class="join-form-helper-input"
|
||||
/>
|
||||
<div class="join-form-helper" aria-hidden="true">
|
||||
<label for="join-website" class="sr-only">{gettext("Website")}</label>
|
||||
<input
|
||||
type="text"
|
||||
name={@honeypot_field}
|
||||
id="join-website"
|
||||
tabindex="-1"
|
||||
autocomplete="off"
|
||||
class="join-form-helper-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-base-content/85">
|
||||
{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."
|
||||
)}
|
||||
</p>
|
||||
|
||||
<p class="text-xs text-base-content/80">
|
||||
{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."
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
{gettext("Submit request")}
|
||||
</button>
|
||||
</div>
|
||||
</.form>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-base-content/70">
|
||||
{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."
|
||||
)}
|
||||
</p>
|
||||
|
||||
<p class="text-xs text-base-content/60">
|
||||
{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."
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
{gettext("Submit request")}
|
||||
</button>
|
||||
</div>
|
||||
</.form>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
</Layouts.public_page>
|
||||
"""
|
||||
end
|
||||
|
||||
|
|
@ -142,8 +160,26 @@ defmodule MvWeb.JoinLive do
|
|||
case build_submit_attrs(params, socket.assigns.join_fields) do
|
||||
{:ok, attrs} ->
|
||||
case Membership.submit_join_request(attrs, actor: nil) do
|
||||
{:ok, _} -> {:noreply, assign(socket, :submitted, true)}
|
||||
{:error, _} -> validation_error_reply(socket, params)
|
||||
{:ok, _} ->
|
||||
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
|
||||
|
||||
{:error, message} ->
|
||||
|
|
@ -161,6 +197,16 @@ defmodule MvWeb.JoinLive do
|
|||
|> assign(:form, to_form(params, as: "join"))}
|
||||
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
|
||||
{:noreply,
|
||||
socket
|
||||
|
|
|
|||
|
|
@ -21,9 +21,24 @@ defmodule MvWeb.JoinRequestLive.Helpers do
|
|||
@doc """
|
||||
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
|
||||
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)
|
||||
|
||||
case user do
|
||||
|
|
@ -42,6 +57,4 @@ defmodule MvWeb.JoinRequestLive.Helpers do
|
|||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def reviewer_display(_), do: nil
|
||||
end
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ defmodule MvWeb.JoinRequestLive.Index do
|
|||
~H"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
{gettext("Join requests")}
|
||||
{@content_title}
|
||||
</.header>
|
||||
|
||||
<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")}>
|
||||
<%= if req.submitted_at do %>
|
||||
{DateFormatter.format_datetime(req.submitted_at)}
|
||||
{DateFormatter.format_datetime(req.submitted_at, @browser_timezone)}
|
||||
<% else %>
|
||||
<.empty_cell sr_text={gettext("Not submitted yet")} />
|
||||
<% end %>
|
||||
|
|
@ -125,7 +125,7 @@ defmodule MvWeb.JoinRequestLive.Index do
|
|||
</.badge>
|
||||
</:col>
|
||||
<:col :let={req} label={gettext("Reviewed at")}>
|
||||
{review_date(req)}
|
||||
{review_date(req, @browser_timezone)}
|
||||
</:col>
|
||||
<:col :let={req} label={gettext("Review by")}>
|
||||
{JoinRequestHelpers.reviewer_display(req) || ""}
|
||||
|
|
@ -159,10 +159,10 @@ defmodule MvWeb.JoinRequestLive.Index do
|
|||
assign(socket, :join_requests_history, [])
|
||||
end
|
||||
|
||||
assign(socket, :page_title, gettext("Join requests"))
|
||||
Layouts.assign_page_title(socket, gettext("Join requests"))
|
||||
end
|
||||
|
||||
defp review_date(req) do
|
||||
defp review_date(req, timezone) do
|
||||
date =
|
||||
case req.status do
|
||||
:approved -> req.approved_at
|
||||
|
|
@ -170,6 +170,6 @@ defmodule MvWeb.JoinRequestLive.Index do
|
|||
_ -> nil
|
||||
end
|
||||
|
||||
if date, do: DateFormatter.format_datetime(date), else: ""
|
||||
if date, do: DateFormatter.format_datetime(date, timezone), else: ""
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ defmodule MvWeb.JoinRequestLive.Show do
|
|||
socket
|
||||
|> assign(:join_request, nil)
|
||||
|> assign(:join_form_field_ids, [])
|
||||
|> assign(:page_title, gettext("Join request"))}
|
||||
|> Layouts.assign_page_title(gettext("Join request"))}
|
||||
else
|
||||
{:ok, redirect(socket, to: ~p"/members")}
|
||||
end
|
||||
|
|
@ -57,7 +57,7 @@ defmodule MvWeb.JoinRequestLive.Show do
|
|||
socket
|
||||
|> assign(:join_request, request)
|
||||
|> 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} ->
|
||||
{:noreply,
|
||||
|
|
@ -123,28 +123,28 @@ defmodule MvWeb.JoinRequestLive.Show do
|
|||
{gettext("Back")}
|
||||
</.button>
|
||||
</:leading>
|
||||
{gettext("Join request")}
|
||||
{@content_title}
|
||||
</.header>
|
||||
|
||||
<%= if @join_request do %>
|
||||
<div class="mt-6 space-y-6 max-w-2xl">
|
||||
<%!-- Single block: all applicant-provided data in join form order --%>
|
||||
<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">
|
||||
<.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
|
||||
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">
|
||||
<span class="text-base-content/60 min-w-32 shrink-0">{gettext("Status")}:</span>
|
||||
|
|
@ -154,34 +154,21 @@ defmodule MvWeb.JoinRequestLive.Show do
|
|||
</.badge>
|
||||
</span>
|
||||
</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 %>
|
||||
<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.status in [:approved, :rejected] do %>
|
||||
<%= if @join_request.approved_at do %>
|
||||
<.field_row
|
||||
label={gettext("Approved at")}
|
||||
value={DateFormatter.format_datetime(@join_request.approved_at)}
|
||||
value={
|
||||
DateFormatter.format_datetime(@join_request.approved_at, @browser_timezone)
|
||||
}
|
||||
/>
|
||||
<% end %>
|
||||
<%= if @join_request.rejected_at do %>
|
||||
<.field_row
|
||||
label={gettext("Rejected at")}
|
||||
value={DateFormatter.format_datetime(@join_request.rejected_at)}
|
||||
value={
|
||||
DateFormatter.format_datetime(@join_request.rejected_at, @browser_timezone)
|
||||
}
|
||||
/>
|
||||
<% end %>
|
||||
<.field_row
|
||||
|
|
@ -189,9 +176,9 @@ defmodule MvWeb.JoinRequestLive.Show do
|
|||
value={JoinRequestHelpers.reviewer_display(@join_request)}
|
||||
empty_text="-"
|
||||
/>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= if @join_request.status == :submitted do %>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 pt-2">
|
||||
|
|
@ -240,40 +227,78 @@ defmodule MvWeb.JoinRequestLive.Show do
|
|||
"""
|
||||
end
|
||||
|
||||
# Formats form_data for display in join-form order; legacy keys (not in current
|
||||
# join_form_field_ids) are appended at the end, sorted by label for stability.
|
||||
# Labels: member field keys → human-readable; UUID keys kept as-is (custom field IDs).
|
||||
defp format_form_data(nil, _ordered_field_ids), do: []
|
||||
|
||||
defp format_form_data(form_data, ordered_field_ids) when is_map(form_data) do
|
||||
# Builds a single list of {label, display_value} for all applicant-provided data in join form
|
||||
# order. Typed fields (email, first_name, last_name) and form_data are merged; legacy
|
||||
# form_data keys (not in current join form config) are appended at the end.
|
||||
defp applicant_data_rows(join_request, ordered_field_ids) do
|
||||
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 =
|
||||
ordered_field_ids
|
||||
|> Enum.filter(&Map.has_key?(form_data, &1))
|
||||
|> 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, value}
|
||||
{label, format_applicant_value(value)}
|
||||
end)
|
||||
|
||||
# Then: keys in form_data that are not in current settings (e.g. removed fields on old requests)
|
||||
legacy_keys =
|
||||
form_data
|
||||
|> 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()
|
||||
|
||||
legacy_entries =
|
||||
Enum.map(legacy_keys, fn key ->
|
||||
label = field_key_to_label(key, member_field_strings)
|
||||
{label, form_data[key]}
|
||||
{label, format_applicant_value(form_data[key])}
|
||||
end)
|
||||
|
||||
in_order ++ legacy_entries
|
||||
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
|
||||
if key in member_field_strings,
|
||||
do: MemberFieldsTranslations.label(String.to_existing_atom(key)),
|
||||
|
|
|
|||
|
|
@ -374,7 +374,7 @@ defmodule MvWeb.MemberLive.Form do
|
|||
id -> Ash.get!(MemberResource, id, load: [:membership_fee_type], actor: actor)
|
||||
end
|
||||
|
||||
page_title =
|
||||
content_title =
|
||||
if is_nil(member), do: gettext("Create Member"), else: gettext("Edit Member")
|
||||
|
||||
# Load available membership fee types
|
||||
|
|
@ -389,7 +389,7 @@ defmodule MvWeb.MemberLive.Form do
|
|||
|> assign(:custom_fields, custom_fields)
|
||||
|> assign(:initial_custom_field_values, initial_custom_field_values)
|
||||
|> assign(member: member)
|
||||
|> assign(:page_title, page_title)
|
||||
|> Layouts.assign_page_title(content_title)
|
||||
|> assign(:available_fee_types, available_fee_types)
|
||||
|> assign(:interval_warning, nil)
|
||||
|> assign(:member_field_required_map, member_field_required_map)
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:page_title, gettext("Members"))
|
||||
|> Layouts.assign_page_title(gettext("Members"))
|
||||
|> assign(:query, "")
|
||||
|> assign_new(:sort_field, fn -> :first_name end)
|
||||
|> assign_new(:sort_order, fn -> :asc end)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
{gettext("Members")}
|
||||
{@content_title}
|
||||
<:actions>
|
||||
<.live_component
|
||||
module={MvWeb.Components.ExportDropdown}
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ defmodule MvWeb.MemberLive.Show do
|
|||
{gettext("Back")}
|
||||
</.button>
|
||||
</:leading>
|
||||
{MemberHelpers.display_name(@member)}
|
||||
{@content_title}
|
||||
<:actions>
|
||||
<%= if can?(@current_user, :update, @member) do %>
|
||||
<.button
|
||||
|
|
@ -435,9 +435,12 @@ defmodule MvWeb.MemberLive.Show do
|
|||
|> Map.put(:last_cycle_status, last_cycle_status)
|
||||
|> Map.put(:current_cycle_status, current_cycle_status)
|
||||
|
||||
content_title =
|
||||
gettext("Member %{name}", name: MemberHelpers.display_name(member))
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:page_title, page_title(socket.assigns.live_action))
|
||||
|> Layouts.assign_page_title(content_title)
|
||||
|> assign(:member, member)}
|
||||
end
|
||||
|
||||
|
|
@ -565,9 +568,6 @@ defmodule MvWeb.MemberLive.Show do
|
|||
{:noreply, assign(socket, :member, member)}
|
||||
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
|
||||
error_messages =
|
||||
Enum.map(errors, fn
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ defmodule MvWeb.MembershipFeeSettingsLive do
|
|||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, gettext("Membership Fee Settings"))
|
||||
|> Layouts.assign_page_title(gettext("Membership fee settings"))
|
||||
|> assign(:settings, settings)
|
||||
|> assign(:membership_fee_types, membership_fee_types)
|
||||
|> assign(:member_counts, member_counts)
|
||||
|
|
@ -140,7 +140,7 @@ defmodule MvWeb.MembershipFeeSettingsLive do
|
|||
~H"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
{gettext("Membership Fee Settings")}
|
||||
{@content_title}
|
||||
<:subtitle>
|
||||
{gettext("Configure fee types for membership fees.")}
|
||||
</:subtitle>
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
|
|||
{gettext("Back")}
|
||||
</.button>
|
||||
</:leading>
|
||||
{@page_title}
|
||||
{@content_title}
|
||||
<:actions>
|
||||
<.button
|
||||
form="membership-fee-type-form"
|
||||
|
|
@ -221,7 +221,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
|
|||
id -> Ash.get!(MembershipFeeType, id, domain: MembershipFees, actor: actor)
|
||||
end
|
||||
|
||||
page_title =
|
||||
content_title =
|
||||
if is_nil(membership_fee_type),
|
||||
do: gettext("New Membership Fee Type"),
|
||||
else: gettext("Edit Membership Fee Type")
|
||||
|
|
@ -230,7 +230,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
|
|||
socket
|
||||
|> assign(:return_to, return_to(params["return_to"]))
|
||||
|> assign(:membership_fee_type, membership_fee_type)
|
||||
|> assign(:page_title, page_title)
|
||||
|> Layouts.assign_page_title(content_title)
|
||||
|> assign(:show_amount_warning, false)
|
||||
|> assign(:old_amount, nil)
|
||||
|> assign(:new_amount, nil)
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
|
|||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, gettext("Membership Fee Types"))
|
||||
|> Layouts.assign_page_title(gettext("Membership fee settings"))
|
||||
|> assign(:membership_fee_types, fee_types)
|
||||
|> assign(:member_counts, member_counts)}
|
||||
end
|
||||
|
|
@ -42,7 +42,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
|
|||
~H"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
{gettext("Membership Fee Types")}
|
||||
{@content_title}
|
||||
<:subtitle>
|
||||
{gettext("Manage membership fee types for membership fees.")}
|
||||
</:subtitle>
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ defmodule MvWeb.RoleLive.Form do
|
|||
{gettext("Back")}
|
||||
</.button>
|
||||
</:leading>
|
||||
{@page_title}
|
||||
{@content_title}
|
||||
<:actions>
|
||||
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
|
||||
{gettext("Save")}
|
||||
|
|
@ -94,14 +94,13 @@ defmodule MvWeb.RoleLive.Form do
|
|||
def mount(params, _session, socket) do
|
||||
case params["id"] do
|
||||
nil ->
|
||||
action = gettext("New")
|
||||
page_title = action <> " " <> gettext("Role")
|
||||
content_title = gettext("New") <> " " <> gettext("Role")
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:return_to, return_to(params["return_to"]))
|
||||
|> assign(:role, nil)
|
||||
|> assign(:page_title, page_title)
|
||||
|> Layouts.assign_page_title(content_title)
|
||||
|> assign_form()}
|
||||
|
||||
id ->
|
||||
|
|
@ -113,14 +112,13 @@ defmodule MvWeb.RoleLive.Form do
|
|||
actor: socket.assigns[:current_user]
|
||||
) do
|
||||
{:ok, role} ->
|
||||
action = gettext("Edit")
|
||||
page_title = action <> " " <> gettext("Role")
|
||||
content_title = gettext("Edit") <> " " <> gettext("Role")
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:return_to, return_to(params["return_to"]))
|
||||
|> assign(:role, role)
|
||||
|> assign(:page_title, page_title)
|
||||
|> Layouts.assign_page_title(content_title)
|
||||
|> assign_form()}
|
||||
|
||||
{:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{} | _]}} ->
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ defmodule MvWeb.RoleLive.Index do
|
|||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, gettext("Listing Roles"))
|
||||
|> Layouts.assign_page_title(gettext("Roles"))
|
||||
|> assign(:roles, roles)
|
||||
|> assign(:user_counts, user_counts)}
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
{gettext("Listing Roles")}
|
||||
{@content_title}
|
||||
<:subtitle>
|
||||
{gettext("Manage roles and their permission sets.")}
|
||||
</:subtitle>
|
||||
|
|
|
|||
|
|
@ -30,9 +30,11 @@ defmodule MvWeb.RoleLive.Show do
|
|||
{:ok, role} ->
|
||||
user_count = load_user_count(role, socket.assigns[:current_user])
|
||||
|
||||
content_title = gettext("Role %{name}", name: role.name)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, gettext("Show Role"))
|
||||
|> Layouts.assign_page_title(content_title)
|
||||
|> assign(:role, role)
|
||||
|> assign(:user_count, user_count)
|
||||
|> assign(:show_delete_modal, false)}
|
||||
|
|
@ -202,7 +204,7 @@ defmodule MvWeb.RoleLive.Show do
|
|||
{gettext("Back")}
|
||||
</.button>
|
||||
</:leading>
|
||||
{gettext("Role")} {@role.name}
|
||||
{@content_title}
|
||||
<:subtitle>{gettext("Role details and permissions.")}</:subtitle>
|
||||
|
||||
<:actions>
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ defmodule MvWeb.StatisticsLive do
|
|||
# Only static assigns and fee types here; load_statistics runs once in handle_params
|
||||
socket =
|
||||
socket
|
||||
|> assign(:page_title, gettext("Statistics"))
|
||||
|> Layouts.assign_page_title(gettext("Statistics"))
|
||||
|> assign(:selected_fee_type_id, nil)
|
||||
|> load_fee_types()
|
||||
|
||||
|
|
@ -58,7 +58,7 @@ defmodule MvWeb.StatisticsLive do
|
|||
~H"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
{gettext("Statistics")}
|
||||
{@content_title}
|
||||
</.header>
|
||||
|
||||
<section class="mb-8" aria-labelledby="members-heading">
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ defmodule MvWeb.UserLive.Form do
|
|||
{gettext("Back")}
|
||||
</.button>
|
||||
</:leading>
|
||||
{@page_title}
|
||||
{@content_title}
|
||||
<:actions>
|
||||
<.button
|
||||
form="user-form"
|
||||
|
|
@ -423,8 +423,9 @@ defmodule MvWeb.UserLive.Form do
|
|||
|
||||
defp mount_continue(user, params, socket) do
|
||||
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).
|
||||
can_manage_member_linking = can?(actor, :destroy, UserResource)
|
||||
|
|
@ -436,7 +437,7 @@ defmodule MvWeb.UserLive.Form do
|
|||
socket
|
||||
|> assign(:return_to, return_to(params["return_to"]))
|
||||
|> 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_assign_role, can_assign_role)
|
||||
|> assign(:roles, roles)
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ defmodule MvWeb.UserLive.Index do
|
|||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, gettext("Listing Users"))
|
||||
|> Layouts.assign_page_title(gettext("Users"))
|
||||
|> assign(:sort_field, :email)
|
||||
|> assign(:sort_order, :asc)
|
||||
|> assign(:users, sorted)}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
{gettext("Users")}
|
||||
{@content_title}
|
||||
<:subtitle>{gettext("Manage users and their permissions.")}</:subtitle>
|
||||
<:actions>
|
||||
<%= if can?(@current_user, :create, Mv.Accounts.User) do %>
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ defmodule MvWeb.UserLive.Show do
|
|||
{gettext("Back")}
|
||||
</.button>
|
||||
</:leading>
|
||||
{gettext("User")} {@user.email}
|
||||
{@content_title}
|
||||
<:actions>
|
||||
<%= if can?(@current_user, :update, @user) do %>
|
||||
<.button
|
||||
|
|
@ -179,9 +179,11 @@ defmodule MvWeb.UserLive.Show do
|
|||
|> put_flash(:error, gettext("This user cannot be viewed."))
|
||||
|> push_navigate(to: ~p"/users")}
|
||||
else
|
||||
content_title = gettext("User %{email}", email: user.email)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, gettext("Show User"))
|
||||
|> Layouts.assign_page_title(content_title)
|
||||
|> assign(:user, user)
|
||||
|> assign(:show_delete_modal, false)}
|
||||
end
|
||||
|
|
|
|||
|
|
@ -17,11 +17,29 @@ defmodule MvWeb.LiveHelpers do
|
|||
"""
|
||||
import Phoenix.Component
|
||||
alias Mv.Authorization.Actor
|
||||
alias Mv.Membership
|
||||
alias MvWeb.Plugs.CheckPagePermission
|
||||
|
||||
def on_mount(:default, _params, session, socket) do
|
||||
locale = session["locale"] || "de"
|
||||
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}
|
||||
end
|
||||
|
||||
|
|
|
|||
22
lib/mv_web/plugs/assign_club_name.ex
Normal file
22
lib/mv_web/plugs/assign_club_name.ex
Normal 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
|
||||
55
lib/mv_web/plugs/registration_enabled.ex
Normal file
55
lib/mv_web/plugs/registration_enabled.ex
Normal 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
|
||||
|
|
@ -14,8 +14,10 @@ defmodule MvWeb.Router do
|
|||
plug :put_secure_browser_headers
|
||||
plug :load_from_session
|
||||
plug :set_locale
|
||||
plug MvWeb.Plugs.AssignClubName
|
||||
plug MvWeb.Plugs.CheckPagePermission
|
||||
plug MvWeb.Plugs.JoinFormEnabled
|
||||
plug MvWeb.Plugs.RegistrationEnabled
|
||||
end
|
||||
|
||||
pipeline :api do
|
||||
|
|
|
|||
10
lib/mv_web/templates/emails/join_already_member.html.heex
Normal file
10
lib/mv_web/templates/emails/join_already_member.html.heex
Normal 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>
|
||||
10
lib/mv_web/templates/emails/join_already_pending.html.heex
Normal file
10
lib/mv_web/templates/emails/join_already_pending.html.heex
Normal 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>
|
||||
|
|
@ -1,4 +1,9 @@
|
|||
<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;">
|
||||
{gettext(
|
||||
"We have received your membership request. To complete it, please click the link below."
|
||||
|
|
|
|||
3
mix.exs
3
mix.exs
|
|
@ -85,7 +85,8 @@ defmodule Mv.MixProject do
|
|||
{:slugify, "~> 1.3"},
|
||||
{:nimble_csv, "~> 1.0"},
|
||||
{:imprintor, "~> 0.5.0"},
|
||||
{:hammer, "~> 7.0"}
|
||||
{:hammer, "~> 7.0"},
|
||||
{:tz, "~> 0.28"}
|
||||
]
|
||||
end
|
||||
|
||||
|
|
|
|||
1
mix.lock
1
mix.lock
|
|
@ -96,6 +96,7 @@
|
|||
"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"},
|
||||
"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"},
|
||||
"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"},
|
||||
|
|
|
|||
|
|
@ -139,18 +139,16 @@ msgid "This OIDC account is already linked to another user. Please contact suppo
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#: lib/mv_web/live/auth/sign_in_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Language selection"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#: lib/mv_web/live/auth/sign_in_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Select language"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/auth_overrides.ex
|
||||
#: lib/mv_web/live/auth/sign_in_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "or"
|
||||
msgid "Register"
|
||||
msgstr ""
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#: lib/mv_web/live/auth/sign_in_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Language selection"
|
||||
msgstr "Sprachauswahl"
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#: lib/mv_web/live/auth/sign_in_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Select language"
|
||||
msgstr "Sprache auswählen"
|
||||
|
||||
#: lib/mv_web/auth_overrides.ex
|
||||
#: lib/mv_web/live/auth/sign_in_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "or"
|
||||
msgstr "oder"
|
||||
msgid "Register"
|
||||
msgstr "Registrieren"
|
||||
|
|
|
|||
|
|
@ -110,11 +110,6 @@ msgstr "Feld hinzufügen"
|
|||
msgid "Add members"
|
||||
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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Address"
|
||||
|
|
@ -356,6 +351,7 @@ msgid "Base URL"
|
|||
msgstr "Basis-URL"
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Basic settings"
|
||||
msgstr "Grundeinstellungen"
|
||||
|
|
@ -1084,7 +1080,6 @@ msgid "Edit Group"
|
|||
msgstr "Gruppe bearbeiten"
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Edit Member"
|
||||
msgstr "Mitglied bearbeiten"
|
||||
|
|
@ -1121,7 +1116,6 @@ msgstr "Rolle bearbeiten"
|
|||
|
||||
#: lib/mv_web/live/group_live/show.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/index.html.heex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
|
|
@ -1374,7 +1368,6 @@ msgid "First Name"
|
|||
msgstr "Vorname"
|
||||
|
||||
#: 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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "First name"
|
||||
|
|
@ -1563,17 +1556,17 @@ msgstr "Hausnummer"
|
|||
#: lib/mv_web/templates/emails/user_confirmation.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "If you did not create an account, you can ignore this email."
|
||||
msgstr "Wenn Sie kein Konto angelegt haben, können Sie diese E-Mail ignorieren."
|
||||
msgstr "Wenn du kein Konto angelegt hast, kannst du diese E-Mail ignorieren."
|
||||
|
||||
#: lib/mv_web/templates/emails/password_reset.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "If you did not request this, you can ignore this email. Your password will remain unchanged."
|
||||
msgstr "Wenn Sie das nicht angefordert haben, können Sie diese E-Mail ignorieren. Ihr Passwort bleibt unverändert."
|
||||
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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "If you did not submit this request, you can ignore this email."
|
||||
msgstr "Wenn Sie diese Anfrage nicht gestellt haben, können Sie diese E-Mail ignorieren."
|
||||
msgstr "Wenn du diese Anfrage nicht gestellt hast, kannst du diese E-Mail ignorieren."
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.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"
|
||||
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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Import Results"
|
||||
|
|
@ -1695,7 +1683,7 @@ msgstr "Ungültiges Datumsformat"
|
|||
msgid "Invalid email address. Please enter a valid recipient address."
|
||||
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
|
||||
msgid "Invalid or expired link."
|
||||
msgstr "Ungültiger oder abgelaufener Link."
|
||||
|
|
@ -1792,7 +1780,6 @@ msgid "Last Name"
|
|||
msgstr "Nachname"
|
||||
|
||||
#: 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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Last name"
|
||||
|
|
@ -1836,17 +1823,6 @@ msgstr "Verknüpftes Mitglied"
|
|||
msgid "Linked User"
|
||||
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
|
||||
#, elixir-autogen, elixir-format
|
||||
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/show.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_type_live/index.ex
|
||||
#: lib/mv_web/live/statistics_live.ex
|
||||
|
|
@ -2018,11 +1993,6 @@ msgstr "Mitgliedertabelle"
|
|||
msgid "Membership Fee"
|
||||
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/translations/member_fields.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
|
|
@ -2042,7 +2012,6 @@ msgid "Membership Fee Type"
|
|||
msgstr "Mitgliedsbeitragsart"
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Membership Fee Types"
|
||||
msgstr "Mitgliedsbeitragsarten"
|
||||
|
|
@ -2054,6 +2023,8 @@ msgid "Membership Fees"
|
|||
msgstr "Mitgliedsbeiträge"
|
||||
|
||||
#: 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
|
||||
msgid "Membership fee settings"
|
||||
msgstr "Beitragseinstellungen"
|
||||
|
|
@ -2178,6 +2149,7 @@ msgstr "Neuer Betrag"
|
|||
|
||||
#: lib/mv_web/live/components/member_filter_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_live/index/formatter.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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Please confirm your email address by clicking the link below."
|
||||
msgstr "Bitte bestätigen Sie Ihre E-Mail-Adresse, indem Sie auf den folgenden Link klicken."
|
||||
msgstr "Bitte bestätige deine E-Mail-Adresse, indem du auf den folgenden Link klickst."
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -2681,11 +2653,6 @@ msgstr "Mitglied aus Gruppe entfernen"
|
|||
msgid "Reorder"
|
||||
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/index_component.ex
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
|
|
@ -2711,18 +2678,12 @@ msgstr "Passwort zurücksetzen"
|
|||
msgid "Review by"
|
||||
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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Reviewed at"
|
||||
msgstr "Geprüft am"
|
||||
|
||||
#: 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/index.html.heex
|
||||
#: lib/mv_web/live/user_live/show.ex
|
||||
|
|
@ -2752,6 +2713,7 @@ msgid "Role saved successfully."
|
|||
msgstr "Rolle erfolgreich gespeichert."
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#: lib/mv_web/live/role_live/index.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Roles"
|
||||
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."
|
||||
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
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Settings saved successfully."
|
||||
|
|
@ -3008,21 +2965,6 @@ msgstr "Einstellungen erfolgreich gespeichert"
|
|||
msgid "Show"
|
||||
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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Show bookings/receipts from Vereinfacht"
|
||||
|
|
@ -3214,10 +3156,10 @@ msgstr "Wird getestet..."
|
|||
msgid "Text"
|
||||
msgstr "Textfeld"
|
||||
|
||||
#: lib/mv_web/controllers/join_confirm_controller.ex
|
||||
#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
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
|
||||
#, 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."
|
||||
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
|
||||
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
|
||||
#, 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:"
|
||||
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
|
||||
msgid "Toggle dark mode"
|
||||
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."
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#: lib/mv_web/live/user_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "User"
|
||||
msgstr "Benutzer*in"
|
||||
|
|
@ -3446,7 +3387,7 @@ msgstr "Benutzername"
|
|||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#: 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
|
||||
msgid "Users"
|
||||
msgstr "Benutzer*innen"
|
||||
|
|
@ -3534,7 +3475,7 @@ msgstr "Keine Internetverbindung gefunden"
|
|||
#: lib/mv_web/templates/emails/join_confirmation.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "We have received your membership request. To complete it, please click the link below."
|
||||
msgstr "Wir haben Ihre Mitgliedschaftsanfrage erhalten. Bitte klicken Sie zur Bestätigung auf den folgenden Link."
|
||||
msgstr "Wir haben deine Mitgliedschaftsanfrage erhalten. Bitte klicke zur Bestätigung auf den folgenden Link."
|
||||
|
||||
#: lib/mv_web/live/join_live.ex
|
||||
#, 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/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_live/index/formatter.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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You requested a password reset. Click the link below to set a new password."
|
||||
msgstr "Sie haben die Zurücksetzung Ihres Passworts angefordert. Klicken Sie auf den folgenden Link, um ein neues Passwort zu setzen."
|
||||
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
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -3776,3 +3718,180 @@ msgstr "aktualisiert"
|
|||
#, elixir-autogen, elixir-format
|
||||
msgid "without %{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}"
|
||||
|
|
|
|||
|
|
@ -111,11 +111,6 @@ msgstr ""
|
|||
msgid "Add members"
|
||||
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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Address"
|
||||
|
|
@ -357,6 +352,7 @@ msgid "Base URL"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Basic settings"
|
||||
msgstr ""
|
||||
|
|
@ -1085,7 +1081,6 @@ msgid "Edit Group"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Edit Member"
|
||||
msgstr ""
|
||||
|
|
@ -1122,7 +1117,6 @@ msgstr ""
|
|||
|
||||
#: lib/mv_web/live/group_live/show.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/index.html.heex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
|
|
@ -1375,7 +1369,6 @@ msgid "First Name"
|
|||
msgstr ""
|
||||
|
||||
#: 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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "First name"
|
||||
|
|
@ -1582,11 +1575,6 @@ msgstr ""
|
|||
msgid "Import"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Import Members"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Import Results"
|
||||
|
|
@ -1696,7 +1684,7 @@ msgstr ""
|
|||
msgid "Invalid email address. Please enter a valid recipient address."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/controllers/join_confirm_controller.ex
|
||||
#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Invalid or expired link."
|
||||
msgstr ""
|
||||
|
|
@ -1793,7 +1781,6 @@ msgid "Last Name"
|
|||
msgstr ""
|
||||
|
||||
#: 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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Last name"
|
||||
|
|
@ -1837,17 +1824,6 @@ msgstr ""
|
|||
msgid "Linked User"
|
||||
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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Logout"
|
||||
|
|
@ -2000,7 +1976,6 @@ msgstr ""
|
|||
#: lib/mv_web/live/group_live/index.ex
|
||||
#: lib/mv_web/live/group_live/show.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_type_live/index.ex
|
||||
#: lib/mv_web/live/statistics_live.ex
|
||||
|
|
@ -2019,11 +1994,6 @@ msgstr ""
|
|||
msgid "Membership Fee"
|
||||
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/translations/member_fields.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -2043,7 +2013,6 @@ msgid "Membership Fee Type"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Membership Fee Types"
|
||||
msgstr ""
|
||||
|
|
@ -2055,6 +2024,8 @@ msgid "Membership Fees"
|
|||
msgstr ""
|
||||
|
||||
#: 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
|
||||
msgid "Membership fee settings"
|
||||
msgstr ""
|
||||
|
|
@ -2179,6 +2150,7 @@ msgstr ""
|
|||
|
||||
#: lib/mv_web/live/components/member_filter_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_live/index/formatter.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
|
|
@ -2682,11 +2654,6 @@ msgstr ""
|
|||
msgid "Reorder"
|
||||
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/index_component.ex
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
|
|
@ -2712,18 +2679,12 @@ msgstr ""
|
|||
msgid "Review by"
|
||||
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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Reviewed at"
|
||||
msgstr ""
|
||||
|
||||
#: 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/index.html.heex
|
||||
#: lib/mv_web/live/user_live/show.ex
|
||||
|
|
@ -2753,6 +2714,7 @@ msgid "Role saved successfully."
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#: lib/mv_web/live/role_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Roles"
|
||||
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."
|
||||
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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Settings saved successfully."
|
||||
|
|
@ -3009,21 +2966,6 @@ msgstr ""
|
|||
msgid "Show"
|
||||
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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Show bookings/receipts from Vereinfacht"
|
||||
|
|
@ -3215,7 +3157,7 @@ msgstr ""
|
|||
msgid "Text"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/controllers/join_confirm_controller.ex
|
||||
#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Thank you, we have received your request."
|
||||
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."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/controllers/join_confirm_controller.ex
|
||||
#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "This link has expired. Please submit the form again."
|
||||
msgstr ""
|
||||
|
|
@ -3323,7 +3265,7 @@ msgstr ""
|
|||
msgid "To confirm deletion, please enter this text:"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Toggle dark mode"
|
||||
msgstr ""
|
||||
|
|
@ -3408,7 +3350,6 @@ msgid "Use the data field name as the CSV column header in your file. Data field
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#: lib/mv_web/live/user_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "User"
|
||||
msgstr ""
|
||||
|
|
@ -3447,7 +3388,7 @@ msgstr ""
|
|||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#: 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
|
||||
msgid "Users"
|
||||
msgstr ""
|
||||
|
|
@ -3575,6 +3516,7 @@ msgstr ""
|
|||
|
||||
#: lib/mv_web/live/components/member_filter_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_live/index/formatter.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
|
|
@ -3776,3 +3718,180 @@ msgstr ""
|
|||
#, elixir-autogen, elixir-format
|
||||
msgid "without %{name}"
|
||||
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 ""
|
||||
|
|
|
|||
|
|
@ -132,18 +132,16 @@ msgid "This OIDC account is already linked to another user. Please contact suppo
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#: lib/mv_web/live/auth/sign_in_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Language selection"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#: lib/mv_web/live/auth/sign_in_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Select language"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/auth_overrides.ex
|
||||
#: lib/mv_web/live/auth/sign_in_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "or"
|
||||
msgstr "or"
|
||||
msgid "Register"
|
||||
msgstr ""
|
||||
|
|
|
|||
|
|
@ -111,11 +111,6 @@ msgstr ""
|
|||
msgid "Add members"
|
||||
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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Address"
|
||||
|
|
@ -357,6 +352,7 @@ msgid "Base URL"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Basic settings"
|
||||
msgstr ""
|
||||
|
|
@ -1085,7 +1081,6 @@ msgid "Edit Group"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Edit Member"
|
||||
msgstr ""
|
||||
|
|
@ -1122,7 +1117,6 @@ msgstr ""
|
|||
|
||||
#: lib/mv_web/live/group_live/show.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/index.html.heex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
|
|
@ -1375,7 +1369,6 @@ msgid "First Name"
|
|||
msgstr ""
|
||||
|
||||
#: 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
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "First name"
|
||||
|
|
@ -1582,11 +1575,6 @@ msgstr "If you did not submit this request, you can ignore this email."
|
|||
msgid "Import"
|
||||
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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Import Results"
|
||||
|
|
@ -1696,7 +1684,7 @@ msgstr ""
|
|||
msgid "Invalid email address. Please enter a valid recipient address."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/controllers/join_confirm_controller.ex
|
||||
#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Invalid or expired link."
|
||||
msgstr "Invalid or expired link."
|
||||
|
|
@ -1793,7 +1781,6 @@ msgid "Last Name"
|
|||
msgstr ""
|
||||
|
||||
#: 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
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Last name"
|
||||
|
|
@ -1837,17 +1824,6 @@ msgstr ""
|
|||
msgid "Linked User"
|
||||
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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Logout"
|
||||
|
|
@ -2000,7 +1976,6 @@ msgstr ""
|
|||
#: lib/mv_web/live/group_live/index.ex
|
||||
#: lib/mv_web/live/group_live/show.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_type_live/index.ex
|
||||
#: lib/mv_web/live/statistics_live.ex
|
||||
|
|
@ -2019,11 +1994,6 @@ msgstr ""
|
|||
msgid "Membership Fee"
|
||||
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/translations/member_fields.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
|
|
@ -2043,7 +2013,6 @@ msgid "Membership Fee Type"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Membership Fee Types"
|
||||
msgstr ""
|
||||
|
|
@ -2055,6 +2024,8 @@ msgid "Membership Fees"
|
|||
msgstr ""
|
||||
|
||||
#: 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
|
||||
msgid "Membership fee settings"
|
||||
msgstr ""
|
||||
|
|
@ -2179,6 +2150,7 @@ msgstr ""
|
|||
|
||||
#: lib/mv_web/live/components/member_filter_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_live/index/formatter.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
|
|
@ -2682,11 +2654,6 @@ msgstr ""
|
|||
msgid "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/index_component.ex
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
|
|
@ -2712,18 +2679,12 @@ msgstr "Reset your password"
|
|||
msgid "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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Reviewed at"
|
||||
msgstr "Review date"
|
||||
|
||||
#: 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/index.html.heex
|
||||
#: lib/mv_web/live/user_live/show.ex
|
||||
|
|
@ -2753,6 +2714,7 @@ msgid "Role saved successfully."
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#: lib/mv_web/live/role_live/index.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Roles"
|
||||
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."
|
||||
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
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Settings saved successfully."
|
||||
|
|
@ -3009,21 +2966,6 @@ msgstr ""
|
|||
msgid "Show"
|
||||
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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Show bookings/receipts from Vereinfacht"
|
||||
|
|
@ -3215,7 +3157,7 @@ msgstr ""
|
|||
msgid "Text"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/controllers/join_confirm_controller.ex
|
||||
#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "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."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/controllers/join_confirm_controller.ex
|
||||
#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "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:"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Toggle dark mode"
|
||||
msgstr ""
|
||||
|
|
@ -3408,7 +3350,6 @@ msgid "Use the data field name as the CSV column header in your file. Data field
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#: lib/mv_web/live/user_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "User"
|
||||
msgstr ""
|
||||
|
|
@ -3447,7 +3388,7 @@ msgstr ""
|
|||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#: 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
|
||||
msgid "Users"
|
||||
msgstr ""
|
||||
|
|
@ -3575,6 +3516,7 @@ msgstr ""
|
|||
|
||||
#: lib/mv_web/live/components/member_filter_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_live/index/formatter.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
|
|
@ -3776,3 +3718,180 @@ msgstr ""
|
|||
#, elixir-autogen, elixir-format
|
||||
msgid "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}"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -263,6 +263,21 @@ default_hidden_in_overview = %{
|
|||
"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
|
||||
{:ok, existing_settings} ->
|
||||
updates =
|
||||
|
|
@ -304,7 +319,9 @@ case Membership.get_settings() do
|
|||
|> Ash.Changeset.for_create(:create, %{
|
||||
club_name: default_club_name,
|
||||
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!()
|
||||
end
|
||||
|
|
|
|||
|
|
@ -481,19 +481,28 @@ for {email, values} <- custom_value_assignments do
|
|||
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
|
||||
{:ok, settings} ->
|
||||
unless settings.join_form_enabled do
|
||||
Membership.update_settings(settings, %{
|
||||
join_form_enabled: true,
|
||||
join_form_field_ids: settings.join_form_field_ids || ["email", "first_name", "last_name", "city"],
|
||||
join_form_field_required: settings.join_form_field_required || %{
|
||||
"email" => true,
|
||||
"first_name" => false,
|
||||
"last_name" => false,
|
||||
"city" => false
|
||||
}
|
||||
join_form_field_ids: settings.join_form_field_ids || default_join_form_field_ids,
|
||||
join_form_field_required: settings.join_form_field_required || default_join_form_field_required
|
||||
})
|
||||
end
|
||||
_ ->
|
||||
|
|
|
|||
|
|
@ -67,6 +67,18 @@ defmodule Mv.Membership.JoinRequestApprovalDomainTest do
|
|||
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
|
||||
test "reject does not create a member" do
|
||||
request = Fixtures.submitted_join_request_fixture()
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ defmodule Mv.Membership.JoinRequestApprovalPolicyTest do
|
|||
assert approved.status == :approved
|
||||
assert approved.approved_at != nil
|
||||
assert approved.reviewed_by_user_id == user.id
|
||||
assert approved.reviewed_by_display == to_string(user.email)
|
||||
end
|
||||
|
||||
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.rejected_at != nil
|
||||
assert rejected.reviewed_by_user_id == user.id
|
||||
assert rejected.reviewed_by_display == to_string(user.email)
|
||||
end
|
||||
|
||||
test "admin can reject a submitted join request", %{request: request} do
|
||||
|
|
|
|||
33
test/membership/join_request_submit_email_failure_test.exs
Normal file
33
test/membership/join_request_submit_email_failure_test.exs
Normal 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
|
||||
|
|
@ -12,7 +12,12 @@ defmodule Mv.Membership.JoinRequestTest do
|
|||
"""
|
||||
use Mv.DataCase, async: true
|
||||
|
||||
require Ash.Query
|
||||
import Ash.Expr
|
||||
|
||||
alias Mv.Fixtures
|
||||
alias Mv.Membership
|
||||
alias Mv.Membership.JoinRequest
|
||||
|
||||
# Valid minimal attributes for submit (email required; confirmation_token optional for tests)
|
||||
@valid_submit_attrs %{
|
||||
|
|
@ -136,6 +141,60 @@ defmodule Mv.Membership.JoinRequestTest do
|
|||
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
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -56,13 +56,6 @@ defmodule Mv.Membership.SettingJoinFormTest do
|
|||
Membership.update_settings(settings, attrs)
|
||||
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 ----
|
||||
|
||||
describe "join form settings persistence and loading" do
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ defmodule MvWeb.AuthControllerTest do
|
|||
import Phoenix.ConnTest
|
||||
import ExUnit.CaptureLog
|
||||
|
||||
alias Mv.Membership
|
||||
|
||||
# Helper to create an unauthenticated conn (preserves sandbox metadata)
|
||||
defp build_unauthenticated_conn(authenticated_conn) do
|
||||
# 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"
|
||||
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
|
||||
conn = conn_with_oidc_user(authenticated_conn)
|
||||
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"
|
||||
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
|
||||
test "unauthenticated user accessing protected route gets redirected to sign-in", %{
|
||||
conn: authenticated_conn
|
||||
|
|
|
|||
63
test/mv_web/helpers/date_formatter_test.exs
Normal file
63
test/mv_web/helpers/date_formatter_test.exs
Normal 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
|
||||
|
|
@ -144,8 +144,8 @@ defmodule MvWeb.GroupLive.IndexTest do
|
|||
# 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
|
||||
# Allow overhead for authorization, LiveView setup, and other initialization queries
|
||||
assert final_count <= 12,
|
||||
"Expected max 12 queries (groups list + batch member counts + LiveView setup + auth), got #{final_count}. This suggests N+1 query problem."
|
||||
assert final_count <= 13,
|
||||
"Expected max 13 queries (groups list + batch member counts + LiveView setup + auth), got #{final_count}. This suggests N+1 query problem."
|
||||
end
|
||||
|
||||
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)
|
||||
# Expected: 1 query for groups + 1 batch query for member counts + LiveView setup queries
|
||||
# Allow overhead for authorization, LiveView setup, and other initialization queries
|
||||
assert final_count <= 12,
|
||||
"Expected max 12 queries (groups + batch member counts + LiveView setup + auth), got #{final_count}. This suggests inefficient member count calculation."
|
||||
assert final_count <= 13,
|
||||
"Expected max 13 queries (groups + batch member counts + LiveView setup + auth), got #{final_count}. This suggests inefficient member count calculation."
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -253,8 +253,8 @@ defmodule MvWeb.GroupLive.ShowTest do
|
|||
|
||||
# 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.
|
||||
assert final_count <= 22,
|
||||
"Expected max 22 queries (group + members preload + member_count + layout + auth), got #{final_count}. This suggests N+1 query problem."
|
||||
assert final_count <= 23,
|
||||
"Expected max 23 queries (group + members preload + member_count + layout + auth), got #{final_count}. This suggests N+1 query problem."
|
||||
end
|
||||
|
||||
test "slug lookup is efficient (uses unique_slug index)", %{conn: conn} do
|
||||
|
|
|
|||
58
test/mv_web/live/join_live_email_failure_test.exs
Normal file
58
test/mv_web/live/join_live_email_failure_test.exs
Normal 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
|
||||
|
|
@ -9,7 +9,8 @@ defmodule MvWeb.JoinLiveTest do
|
|||
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".
|
||||
"""
|
||||
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 Ecto.Query
|
||||
|
||||
|
|
@ -33,14 +34,15 @@ defmodule MvWeb.JoinLiveTest do
|
|||
end
|
||||
|
||||
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
|
||||
test "submit with valid allowlist data creates one JoinRequest and shows success copy", %{
|
||||
conn: conn
|
||||
} 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()
|
||||
{:ok, view, _html} = live(conn, "/join")
|
||||
|
||||
|
|
@ -53,6 +55,9 @@ defmodule MvWeb.JoinLiveTest do
|
|||
})
|
||||
|> render_submit()
|
||||
|
||||
# Anti-enumeration delay is applied in LiveView via send_after (100–300 ms); wait for success UI.
|
||||
Process.sleep(400)
|
||||
|
||||
assert count_join_requests() == count_before + 1
|
||||
assert view |> element("[data-testid='join-success-message']") |> has_element?()
|
||||
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", %{
|
||||
conn: conn
|
||||
} 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"
|
||||
count_before = count_join_requests()
|
||||
sandbox = conn.private[:ecto_sandbox]
|
||||
|
|
@ -168,4 +156,10 @@ defmodule MvWeb.JoinLiveTest do
|
|||
defp count_join_requests do
|
||||
Repo.one(from j in "join_requests", select: count(j.id)) || 0
|
||||
end
|
||||
|
||||
defp reset_rate_limiter do
|
||||
:ets.delete_all_objects(MvWeb.JoinRateLimit)
|
||||
rescue
|
||||
ArgumentError -> :ok
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -70,7 +70,9 @@ defmodule MvWeb.MemberLive.IndexGroupsFilterTest do
|
|||
|
||||
# Force LiveView to process {:group_filter_changed, ...} (render triggers mailbox processing)
|
||||
_ = 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
|
||||
|
||||
test "filter All (default) shows all members", %{
|
||||
|
|
@ -96,7 +98,8 @@ defmodule MvWeb.MemberLive.IndexGroupsFilterTest do
|
|||
conn = conn_with_oidc_user(conn)
|
||||
{: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)
|
||||
assert html =~ m1.first_name
|
||||
|
|
@ -114,7 +117,7 @@ defmodule MvWeb.MemberLive.IndexGroupsFilterTest do
|
|||
conn = conn_with_oidc_user(conn)
|
||||
{: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)
|
||||
refute html =~ m1.first_name
|
||||
|
|
@ -132,7 +135,7 @@ defmodule MvWeb.MemberLive.IndexGroupsFilterTest do
|
|||
conn = conn_with_oidc_user(conn)
|
||||
{: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)
|
||||
assert html =~ m1.first_name
|
||||
|
|
|
|||
|
|
@ -24,16 +24,15 @@ defmodule MvWeb.UserLive.IndexTest do
|
|||
|
||||
@tag :ui
|
||||
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 = Plug.Test.init_test_session(conn, locale: "de")
|
||||
{: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")
|
||||
{:ok, _view, html_en} = live(conn, "/users")
|
||||
assert html_en =~ "Listing Users"
|
||||
assert html_en =~ "Users"
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
10
test/support/failing_mail_adapter.ex
Normal file
10
test/support/failing_mail_adapter.ex
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue