Compare commits
31 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f8a3cc4c47 | |||
| c381b86b5e | |||
| 9b0f269ab6 | |||
| f353f1cbc0 | |||
| e8f27690a1 | |||
| e95c1d6254 | |||
| 837f5fd5bf | |||
| 1866c79461 | |||
| 171a699326 | |||
| 86c032004e | |||
| a4239ce09b | |||
| c933144920 | |||
| e8ec620d57 | |||
| 349cee0ce6 | |||
| f12da8a359 | |||
| d54393d80b | |||
| 5e39fffce2 | |||
| 9a3cf74871 | |||
| 09e4b64663 | |||
| eb18209669 | |||
| 104faf7006 | |||
| 99a8d64344 | |||
| 086ecdcb1b | |||
| 40a4461d23 | |||
| a7481f6ab1 | |||
| d94f9ae42e | |||
| a5ce7cb921 | |||
| 942f2afd9e | |||
| 4af80a8305 | |||
| a4f3aa5d6f | |||
| c4135308e6 |
127 changed files with 15675 additions and 10260 deletions
13
.env.example
13
.env.example
|
|
@ -14,6 +14,7 @@ ASSOCIATION_NAME="Sportsclub XYZ"
|
||||||
# Optional: Admin user (created/updated on container start via Release.seed_admin)
|
# Optional: Admin user (created/updated on container start via Release.seed_admin)
|
||||||
# In production, set these so the first admin can log in. Change password without redeploy:
|
# In production, set these so the first admin can log in. Change password without redeploy:
|
||||||
# bin/mv eval "Mv.Release.seed_admin()" (with new ADMIN_PASSWORD or ADMIN_PASSWORD_FILE)
|
# bin/mv eval "Mv.Release.seed_admin()" (with new ADMIN_PASSWORD or ADMIN_PASSWORD_FILE)
|
||||||
|
# FORCE_SEEDS=true re-runs bootstrap seeds even when admin user exists (e.g. after changing roles/custom fields).
|
||||||
# ADMIN_EMAIL=admin@example.com
|
# ADMIN_EMAIL=admin@example.com
|
||||||
# ADMIN_PASSWORD=secure-password
|
# ADMIN_PASSWORD=secure-password
|
||||||
# ADMIN_PASSWORD_FILE=/run/secrets/admin_password
|
# ADMIN_PASSWORD_FILE=/run/secrets/admin_password
|
||||||
|
|
@ -41,3 +42,15 @@ ASSOCIATION_NAME="Sportsclub XYZ"
|
||||||
# VEREINFACHT_API_KEY=your-api-key
|
# VEREINFACHT_API_KEY=your-api-key
|
||||||
# VEREINFACHT_CLUB_ID=2
|
# VEREINFACHT_CLUB_ID=2
|
||||||
# VEREINFACHT_APP_URL=https://app.verein.visuel.dev
|
# VEREINFACHT_APP_URL=https://app.verein.visuel.dev
|
||||||
|
|
||||||
|
# Optional: Mail / SMTP (transactional emails). If set, overrides Settings UI.
|
||||||
|
# Export current UI settings to .env: mix mv.export_smtp_to_env
|
||||||
|
# SMTP_HOST=smtp.example.com
|
||||||
|
# SMTP_PORT=587
|
||||||
|
# SMTP_USERNAME=user
|
||||||
|
# SMTP_PASSWORD=secret
|
||||||
|
# SMTP_PASSWORD_FILE=/run/secrets/smtp_password
|
||||||
|
# SMTP_SSL=tls
|
||||||
|
# SMTP_VERIFY_PEER=false
|
||||||
|
# MAIL_FROM_EMAIL=noreply@example.com
|
||||||
|
# MAIL_FROM_NAME=Mila
|
||||||
|
|
|
||||||
38
CHANGELOG.md
38
CHANGELOG.md
|
|
@ -5,7 +5,43 @@ All notable changes to this project will be documented in this file.
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [Unreleased]
|
## [1.1.1] - 2026-03-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **FORCE_SEEDS** – Environment variable. When set to `"true"`, bootstrap (and optionally dev) seeds are run even when the admin user already exists, so you can re-apply changed seed data (e.g. new roles or custom fields) without deleting the admin user.
|
||||||
|
- **Improved OIDC-only mode** – Admin can enable “Only OIDC sign-in” in settings; when enabled, direct registration is disabled and sign-in page redirects to OIDC when configured.
|
||||||
|
- **Success toast auto-dismiss** – Success flash messages (e.g. “Settings saved”) hide automatically after 5 seconds instead of requiring the user to close them.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Seeds run only when needed** – Bootstrap and dev seeds are skipped on application start when the admin user already exists (`Mv.Release.bootstrap_seeds_applied?/0`). This avoids duplicate data and speeds up startup in dev and production after the first run. Set `FORCE_SEEDS=true` to override and re-run.
|
||||||
|
- **Unauthenticated access** – Users who are not logged in are redirected to sign-in without showing a “no permission” message; the message is only shown to logged-in users who lack access.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **SMTP configuration** – Repaired so that both port 587 (TLS/STARTTLS) and 465 (SSL) work correctly.
|
||||||
|
|
||||||
|
## [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
|
### Added
|
||||||
- **Roles and Permissions System (RBAC)** - Complete implementation (#345, 2026-01-08)
|
- **Roles and Permissions System (RBAC)** - Complete implementation (#345, 2026-01-08)
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,8 @@ lib/
|
||||||
│ ├── custom_field.ex # Custom field (definition) resource
|
│ ├── custom_field.ex # Custom field (definition) resource
|
||||||
│ ├── custom_field_value.ex # Custom field value resource
|
│ ├── custom_field_value.ex # Custom field value resource
|
||||||
│ ├── setting.ex # Global settings (singleton resource; incl. join form config)
|
│ ├── setting.ex # Global settings (singleton resource; incl. join form config)
|
||||||
|
│ ├── settings_cache.ex # Process cache for get_settings (TTL; invalidate on update; not started in test)
|
||||||
|
│ ├── join_notifier.ex # Behaviour for join emails (confirmation, already member, already pending)
|
||||||
│ ├── setting/ # Setting changes (NormalizeJoinFormSettings, etc.)
|
│ ├── setting/ # Setting changes (NormalizeJoinFormSettings, etc.)
|
||||||
│ ├── group.ex # Group resource
|
│ ├── group.ex # Group resource
|
||||||
│ ├── member_group.ex # MemberGroup join table resource
|
│ ├── member_group.ex # MemberGroup join table resource
|
||||||
|
|
@ -128,6 +130,8 @@ lib/
|
||||||
│ ├── constants.ex # Application constants (member_fields, custom_field_prefix, vereinfacht_required_member_fields)
|
│ ├── constants.ex # Application constants (member_fields, custom_field_prefix, vereinfacht_required_member_fields)
|
||||||
│ ├── application.ex # OTP application
|
│ ├── application.ex # OTP application
|
||||||
│ ├── mailer.ex # Email mailer
|
│ ├── mailer.ex # Email mailer
|
||||||
|
│ ├── smtp/
|
||||||
|
│ │ └── config_builder.ex # SMTP adapter opts (TLS/sockopts); used by runtime.exs and Mailer
|
||||||
│ ├── release.ex # Release tasks
|
│ ├── release.ex # Release tasks
|
||||||
│ ├── repo.ex # Database repository
|
│ ├── repo.ex # Database repository
|
||||||
│ ├── secrets.ex # Secret management
|
│ ├── secrets.ex # Secret management
|
||||||
|
|
@ -280,13 +284,13 @@ end
|
||||||
|
|
||||||
### 1.2.1 Database Seeds
|
### 1.2.1 Database Seeds
|
||||||
|
|
||||||
Seeds are split into **bootstrap** and **dev**:
|
Seeds are split into **bootstrap** and **dev**. They run on every start (e.g. `just run`, Docker entrypoint) but **exit early** if already applied so startup stays fast and no duplicate data is created.
|
||||||
|
|
||||||
- **`priv/repo/seeds.exs`** – Entrypoint. Runs `seeds_bootstrap.exs` always; runs `seeds_dev.exs` only when `Mix.env()` is `:dev` or `:test`.
|
- **`priv/repo/seeds.exs`** – Entrypoint. If the admin user (ADMIN_EMAIL or default) already exists, skips entirely (unless `FORCE_SEEDS=true`); otherwise runs `seeds_bootstrap.exs` and, in dev/test, `seeds_dev.exs`.
|
||||||
- **`priv/repo/seeds_bootstrap.exs`** – Creates only data required for system startup: membership fee types, custom fields, roles, admin user, system user, global settings (including default membership fee type). No members, no groups. Used in all environments (dev, test, prod).
|
- **`priv/repo/seeds_bootstrap.exs`** – Creates only data required for system startup: membership fee types, custom fields, roles, admin user, system user, global settings (including default membership fee type). No members, no groups. Used in all environments (dev, test, prod).
|
||||||
- **`priv/repo/seeds_dev.exs`** – Creates 20 sample members, groups, and optional custom field values. Run only in dev and test.
|
- **`priv/repo/seeds_dev.exs`** – Creates 20 sample members, groups, and optional custom field values. Run only in dev and test.
|
||||||
|
|
||||||
In production, running `mix run priv/repo/seeds.exs` executes only the bootstrap part (no dev seeds).
|
In production, running `mix run priv/repo/seeds.exs` (or `Mv.Release.run_seeds/0`) executes only the bootstrap part when not yet applied (no dev seeds unless `RUN_DEV_SEEDS=true`). The “already applied” check uses `Mv.Release.bootstrap_seeds_applied?/0` (admin user exists). Set `FORCE_SEEDS=true` to re-run seeds even when already applied.
|
||||||
|
|
||||||
### 1.3 Domain-Driven Design
|
### 1.3 Domain-Driven Design
|
||||||
|
|
||||||
|
|
@ -1267,7 +1271,34 @@ mix hex.outdated
|
||||||
**Mailer and from address:**
|
**Mailer and from address:**
|
||||||
|
|
||||||
- `Mv.Mailer` (Swoosh) and `Mv.Mailer.mail_from/0` return the configured sender `{name, email}`.
|
- `Mv.Mailer` (Swoosh) and `Mv.Mailer.mail_from/0` return the configured sender `{name, email}`.
|
||||||
- Config: `config :mv, :mail_from, {"Mila", "noreply@example.com"}` in config.exs. In production, runtime.exs overrides from ENV (`MAIL_FROM_NAME`, `MAIL_FROM_EMAIL`).
|
- Sender identity priority: `MAIL_FROM_NAME`/`MAIL_FROM_EMAIL` ENV > Settings `smtp_from_name`/`smtp_from_email` > hardcoded defaults `{"Mila", "noreply@example.com"}`.
|
||||||
|
- Access via `Mv.Config.mail_from_name/0` and `Mv.Config.mail_from_email/0`.
|
||||||
|
- **Important:** On most SMTP servers the sender email must be the same address as `smtp_username` or an alias owned by that account (e.g. Postfix strict relay). Misconfiguration causes a 553 error.
|
||||||
|
|
||||||
|
**SMTP configuration:**
|
||||||
|
|
||||||
|
- 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).
|
||||||
|
- When `SMTP_HOST` ENV is present at boot, `runtime.exs` configures `Swoosh.Adapters.SMTP` automatically.
|
||||||
|
- When SMTP is configured only via Settings, `Mv.Mailer.smtp_config/0` builds the adapter config per-send.
|
||||||
|
- In test environment, `Swoosh.Adapters.Test` is used regardless of SMTP config.
|
||||||
|
- **TLS in OTP 27:** Verify mode defaults to `verify_none` for self-signed/internal certs. Set `SMTP_VERIFY_PEER=true` (or `1`/`yes`) in prod when using public SMTP (Gmail, Mailgun). Config key `:smtp_verify_peer` is set in `runtime.exs` and read by `Mv.Mailer.smtp_config/0`.
|
||||||
|
- **Test email:** `Mv.Mailer.send_test_email(to_email)` sends a transactional test email; returns `{:ok, email}` or `{:error, classified_reason}`. Classified errors: `:sender_rejected`, `:auth_failed`, `:recipient_rejected`, `:tls_failed`, `:connection_failed`, `{:smtp_error, message}`. Each shows a specific message in the UI.
|
||||||
|
- **Production warning:** When SMTP is not configured in production, a warning is shown in the Settings UI. Use `Application.get_env(:mv, :environment, :dev)` (or assign in mount) for environment checks in LiveView/templates; do not use `Mix.env()` at runtime (it is not available in releases).
|
||||||
|
- Access config values via `Mv.Config.smtp_host/0`, `smtp_port/0`, `smtp_username/0`, `smtp_password/0`, `smtp_ssl/0`, `smtp_configured?/0`.
|
||||||
|
|
||||||
|
**AshAuthentication senders:**
|
||||||
|
|
||||||
|
- `SendPasswordResetEmail` and `SendNewUserConfirmationEmail` use `Mv.Mailer.deliver/1` (not `deliver!/1`). Errors are logged via `Logger.error` and not re-raised so they never crash the caller process.
|
||||||
|
|
||||||
|
**Join confirmation email:**
|
||||||
|
|
||||||
|
- Join emails are sent via `Mv.Membership.JoinNotifier` (default impl: `MvWeb.JoinNotifierImpl` calling `JoinConfirmationEmail`, etc.). `MvWeb.Emails.JoinConfirmationEmail` uses `Mailer.deliver(email, Mailer.smtp_config())` so it uses the same SMTP configuration as the test mail (Settings or boot ENV). On delivery failure, `Mv.Membership.submit_join_request/2` returns `{:error, :email_delivery_failed}` (and logs via `Logger.error`); the JoinLive shows an error message and no success UI.
|
||||||
|
|
||||||
**Unified layout (transactional emails):**
|
**Unified layout (transactional emails):**
|
||||||
|
|
||||||
|
|
@ -1287,7 +1318,11 @@ new()
|
||||||
|> put_view(MvWeb.EmailsView)
|
|> put_view(MvWeb.EmailsView)
|
||||||
|> put_layout({MvWeb.EmailLayoutView, "layout.html"})
|
|> put_layout({MvWeb.EmailLayoutView, "layout.html"})
|
||||||
|> render_body("template_name.html", %{assigns})
|
|> render_body("template_name.html", %{assigns})
|
||||||
|> Mailer.deliver!()
|
|
||||||
|
case Mailer.deliver(email) do
|
||||||
|
{:ok, _} -> :ok
|
||||||
|
{:error, reason} -> Logger.error("Email delivery failed: #{inspect(reason)}")
|
||||||
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.12 Internationalization: Gettext
|
### 3.12 Internationalization: Gettext
|
||||||
|
|
@ -1315,13 +1350,16 @@ dgettext("auth", "Sign in with email")
|
||||||
**Extract and Merge:**
|
**Extract and Merge:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Extract new translatable strings
|
# Extract new translatable strings and merge into existing .po files (recommended)
|
||||||
mix gettext.extract
|
mix gettext.extract --merge
|
||||||
|
|
||||||
# Merge into existing translations
|
# Alternative: extract only, then merge separately
|
||||||
|
mix gettext.extract
|
||||||
mix gettext.merge priv/gettext --on-obsolete=mark_as_obsolete
|
mix gettext.merge priv/gettext --on-obsolete=mark_as_obsolete
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Gettext merge workflow:** Prefer `mix gettext.extract --merge` so the `.pot` template is regenerated from source and merged into all locale `.po` files in one step. Edit only the `msgstr` values in `.po` files for translations; do not manually change source references, entry order, or the `.pot` file structure. If Git merge conflicts appear in `.po` or `.pot` files, resolve by removing conflict markers (keeping both sides where appropriate), then run `mix gettext.extract --merge`. If the `.pot` file is corrupted, delete it and run `mix gettext.extract --merge` to regenerate it from source.
|
||||||
|
|
||||||
### 3.13 Task Runner: Just
|
### 3.13 Task Runner: Just
|
||||||
|
|
||||||
**Common Commands:**
|
**Common Commands:**
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,21 @@ For LiveViews that render an edit or new form (e.g. member, group, role, user, c
|
||||||
|
|
||||||
If the `<.header>` is outside the `<.form>`, the submit button must reference the form via the `form` attribute (e.g. `form="user-form"`).
|
If the `<.header>` is outside the `<.form>`, the submit button must reference the form via the `form` attribute (e.g. `form="user-form"`).
|
||||||
|
|
||||||
|
### 2.3 Public / unauthenticated pages (Join, Sign-in, Join Confirm)
|
||||||
|
|
||||||
|
Pages that do not require authentication (e.g. `/join`, `/sign-in`, `/confirm_join/:token`) use a unified layout via the **`Layouts.public_page`** component:
|
||||||
|
|
||||||
|
- **Component:** `Layouts.public_page` renders:
|
||||||
|
- **Header:** Logo + "Mitgliederverwaltung" (left) | Club name centered via absolute positioning | Language selector + theme swap (sun/moon, DaisyUI swap with rotate) (right)
|
||||||
|
- Main content slot, Flash group. No sidebar, no authenticated-layout logic.
|
||||||
|
- **Content:** DaisyUI **hero** section (`hero`, `hero-content`) for the main message or form, so all public pages share the same visual structure. The hero is constrained in width (`max-w-4xl mx-auto`) and content is left-aligned (`hero-content flex-col items-start text-left`).
|
||||||
|
- **Locale handling:** The language selector uses `Gettext.get_locale(MvWeb.Gettext)` (backend-specific) to correctly reflect the active locale. `SignInLive` sets both `Gettext.put_locale(MvWeb.Gettext, locale)` and `Gettext.put_locale(locale)` to keep global and backend locales in sync.
|
||||||
|
- **Translations for AshAuthentication components:** 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)
|
## 3) Typography (system)
|
||||||
|
|
||||||
Use these standard roles:
|
Use these standard roles:
|
||||||
|
|
@ -83,16 +98,18 @@ Use these standard roles:
|
||||||
| Role | Use | Class |
|
| Role | Use | Class |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Page title (H1) | main page title | `text-xl font-semibold leading-8` |
|
| Page title (H1) | main page title | `text-xl font-semibold leading-8` |
|
||||||
| Subtitle | helper under title | `text-sm text-base-content/70` |
|
| Subtitle | helper under title | `text-sm text-base-content/85` |
|
||||||
| Section title (H2) | section headings | `text-lg font-semibold` |
|
| Section title (H2) | section headings | `text-lg font-semibold` |
|
||||||
| Helper text | under inputs | `text-sm text-base-content/70` |
|
| Helper text | under inputs | `text-sm text-base-content/85` |
|
||||||
| Fine print | small hints | `text-xs text-base-content/60` |
|
| Fine print | small hints | `text-xs text-base-content/80` |
|
||||||
| Empty state | no data | `text-base-content/60 italic` |
|
| Empty state | no data | `text-base-content/80 italic` |
|
||||||
| Destructive text | danger | `text-error` |
|
| Destructive text | danger | `text-error` |
|
||||||
|
|
||||||
**MUST:** Page titles via `<.header>`.
|
**MUST:** Page titles via `<.header>`.
|
||||||
**MUST:** Section titles via `<.form_section title="…">` (for forms) or a consistent section wrapper (if you introduce a `<.card>` later).
|
**MUST:** Section titles via `<.form_section title="…">` (for forms) or a consistent section wrapper (if you introduce a `<.card>` later).
|
||||||
|
|
||||||
|
**Form labels (WCAG 2.2 AA):** DaisyUI `.label` defaults to 60% opacity and fails contrast. We override it in `app.css` to 85% of `base-content` so labels stay slightly 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)
|
## 4) States: Loading, Empty, Error (mandatory consistency)
|
||||||
|
|
@ -204,6 +221,11 @@ If these cannot be met, use `secondary`/`outline` instead of `ghost`.
|
||||||
- **MUST:** Required fields are marked consistently (UI indicator + accessible text).
|
- **MUST:** Required fields are marked consistently (UI indicator + accessible text).
|
||||||
- **SHOULD:** If required-ness is configurable via settings, display it consistently in the form.
|
- **SHOULD:** If required-ness is configurable via settings, display it consistently in the form.
|
||||||
|
|
||||||
|
### 6.4 Form layout (settings / long forms)
|
||||||
|
- **SHOULD:** On wide viewports, use a responsive grid so related fields share a row and reduce scrolling (e.g. `grid grid-cols-1 lg:grid-cols-2` or `lg:grid-cols-[2fr_5rem_1fr]` for mixed widths).
|
||||||
|
- **SHOULD:** Limit the main content width for readability (e.g. Settings page uses `max-w-4xl mx-auto px-4` around the content area below the header).
|
||||||
|
- **Example:** SMTP settings use three rows on large screens (Host, Port, TLS/SSL | Username, Password | Sender email, Sender name) without subsection labels.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7) Lists, Search & Filters (mandatory UX consistency)
|
## 7) Lists, Search & Filters (mandatory UX consistency)
|
||||||
|
|
|
||||||
1
Justfile
1
Justfile
|
|
@ -10,6 +10,7 @@ install-dependencies:
|
||||||
mix deps.get
|
mix deps.get
|
||||||
|
|
||||||
migrate-database:
|
migrate-database:
|
||||||
|
mix compile
|
||||||
mix ash.setup
|
mix ash.setup
|
||||||
|
|
||||||
reset-database:
|
reset-database:
|
||||||
|
|
|
||||||
|
|
@ -154,6 +154,14 @@
|
||||||
background-color: var(--color-base-100);
|
background-color: var(--color-base-100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* WCAG 2.2 AA (4.5:1 for normal text): Form labels. DaisyUI .label uses 60% opacity,
|
||||||
|
which fails contrast. Override to 85% of base-content so labels stay slightly
|
||||||
|
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.
|
/* WCAG 2.2 AA (4.5:1 for normal text): Badge text must contrast with badge background.
|
||||||
Theme tokens *-content are often too light on * backgrounds in light theme, and
|
Theme tokens *-content are often too light on * backgrounds in light theme, and
|
||||||
badge-soft uses variant as text on a light tint (low contrast). We override
|
badge-soft uses variant as text on a light tint (low contrast). We override
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,14 @@ import Sortable from "../vendor/sortable"
|
||||||
|
|
||||||
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
|
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
|
||||||
|
|
||||||
|
function getBrowserTimezone() {
|
||||||
|
try {
|
||||||
|
return Intl.DateTimeFormat().resolvedOptions().timeZone || null
|
||||||
|
} catch (_e) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Hooks for LiveView components
|
// Hooks for LiveView components
|
||||||
let Hooks = {}
|
let Hooks = {}
|
||||||
|
|
||||||
|
|
@ -105,6 +113,25 @@ Hooks.FocusRestore = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FlashAutoDismiss: after a delay, clear the flash so the toast hides without user clicking X (e.g. success toasts)
|
||||||
|
Hooks.FlashAutoDismiss = {
|
||||||
|
mounted() {
|
||||||
|
const ms = this.el.dataset.autoClearMs
|
||||||
|
if (!ms) return
|
||||||
|
const delay = parseInt(ms, 10)
|
||||||
|
if (delay > 0) {
|
||||||
|
this.timer = setTimeout(() => {
|
||||||
|
const key = this.el.dataset.clearFlashKey || "success"
|
||||||
|
this.pushEvent("lv:clear-flash", {key})
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
destroyed() {
|
||||||
|
if (this.timer) clearTimeout(this.timer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TabListKeydown hook: WCAG tab pattern — prevent default for ArrowLeft/ArrowRight so the server can handle tab switch (roving tabindex)
|
// TabListKeydown hook: WCAG tab pattern — prevent default for ArrowLeft/ArrowRight so the server can handle tab switch (roving tabindex)
|
||||||
Hooks.TabListKeydown = {
|
Hooks.TabListKeydown = {
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|
@ -312,7 +339,10 @@ Hooks.SidebarState = {
|
||||||
|
|
||||||
let liveSocket = new LiveSocket("/live", Socket, {
|
let liveSocket = new LiveSocket("/live", Socket, {
|
||||||
longPollFallbackMs: 2500,
|
longPollFallbackMs: 2500,
|
||||||
params: {_csrf_token: csrfToken},
|
params: {
|
||||||
|
_csrf_token: csrfToken,
|
||||||
|
timezone: getBrowserTimezone()
|
||||||
|
},
|
||||||
hooks: Hooks
|
hooks: Hooks
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,11 +46,18 @@ config :spark,
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# IANA timezone database for DateTime.shift_zone (browser timezone display)
|
||||||
|
config :elixir, :time_zone_database, Tz.TimeZoneDatabase
|
||||||
|
|
||||||
config :mv,
|
config :mv,
|
||||||
ecto_repos: [Mv.Repo],
|
ecto_repos: [Mv.Repo],
|
||||||
generators: [timestamp_type: :utc_datetime],
|
generators: [timestamp_type: :utc_datetime],
|
||||||
ash_domains: [Mv.Membership, Mv.Accounts, Mv.MembershipFees, Mv.Authorization]
|
ash_domains: [Mv.Membership, Mv.Accounts, Mv.MembershipFees, Mv.Authorization]
|
||||||
|
|
||||||
|
# Environment (dev/test/prod). Use this instead of Mix.env() at runtime; Mix.env() is
|
||||||
|
# not available in releases. Set once at compile time via config_env().
|
||||||
|
config :mv, :environment, config_env()
|
||||||
|
|
||||||
# CSV Import configuration
|
# CSV Import configuration
|
||||||
config :mv,
|
config :mv,
|
||||||
csv_import: [
|
csv_import: [
|
||||||
|
|
@ -89,6 +96,10 @@ config :mv, MvWeb.Endpoint,
|
||||||
# at the `config/runtime.exs`.
|
# at the `config/runtime.exs`.
|
||||||
config :mv, Mv.Mailer, adapter: Swoosh.Adapters.Local
|
config :mv, Mv.Mailer, adapter: Swoosh.Adapters.Local
|
||||||
|
|
||||||
|
# SMTP TLS verification: false = allow self-signed/internal certs; true = verify_peer (use for public SMTP).
|
||||||
|
# Overridden in runtime.exs from SMTP_VERIFY_PEER when SMTP is configured via ENV in prod.
|
||||||
|
config :mv, :smtp_verify_peer, false
|
||||||
|
|
||||||
# Default mail "from" address for transactional emails (join confirmation,
|
# Default mail "from" address for transactional emails (join confirmation,
|
||||||
# user confirmation, password reset). Override in config/runtime.exs from ENV.
|
# user confirmation, password reset). Override in config/runtime.exs from ENV.
|
||||||
config :mv, :mail_from, {"Mila", "noreply@example.com"}
|
config :mv, :mail_from, {"Mila", "noreply@example.com"}
|
||||||
|
|
@ -96,6 +107,9 @@ config :mv, :mail_from, {"Mila", "noreply@example.com"}
|
||||||
# Join form rate limiting (Hammer). scale_ms: window in ms, limit: max submits per window per IP.
|
# Join form rate limiting (Hammer). scale_ms: window in ms, limit: max submits per window per IP.
|
||||||
config :mv, :join_rate_limit, scale_ms: 60_000, limit: 10
|
config :mv, :join_rate_limit, scale_ms: 60_000, limit: 10
|
||||||
|
|
||||||
|
# Join emails: notifier implementation (domain → web abstraction). Override in test to inject a mock.
|
||||||
|
config :mv, :join_notifier, MvWeb.JoinNotifierImpl
|
||||||
|
|
||||||
# Configure esbuild (the version is required)
|
# Configure esbuild (the version is required)
|
||||||
config :esbuild,
|
config :esbuild,
|
||||||
version: "0.17.11",
|
version: "0.17.11",
|
||||||
|
|
|
||||||
|
|
@ -223,19 +223,52 @@ if config_env() == :prod do
|
||||||
{System.get_env("MAIL_FROM_NAME", "Mila"),
|
{System.get_env("MAIL_FROM_NAME", "Mila"),
|
||||||
System.get_env("MAIL_FROM_EMAIL", "noreply@example.com")}
|
System.get_env("MAIL_FROM_EMAIL", "noreply@example.com")}
|
||||||
|
|
||||||
# In production you may need to configure the mailer to use a different adapter.
|
# SMTP configuration from environment variables (overrides base adapter in prod).
|
||||||
# Also, you may need to configure the Swoosh API client of your choice if you
|
# When SMTP_HOST is set, configure Swoosh to use the SMTP adapter at boot time.
|
||||||
# are not using SMTP. Here is an example of the configuration:
|
# If SMTP is configured only via Settings (Admin UI), the mailer builds the config
|
||||||
#
|
# per-send at runtime using Mv.Mailer.smtp_config/0 (which uses the same Mv.Smtp.ConfigBuilder).
|
||||||
# config :mv, Mv.Mailer,
|
smtp_host_env = System.get_env("SMTP_HOST")
|
||||||
# adapter: Swoosh.Adapters.Mailgun,
|
|
||||||
# api_key: System.get_env("MAILGUN_API_KEY"),
|
if smtp_host_env && String.trim(smtp_host_env) != "" do
|
||||||
# domain: System.get_env("MAILGUN_DOMAIN")
|
smtp_port_env =
|
||||||
#
|
case System.get_env("SMTP_PORT") do
|
||||||
# For this example you need include a HTTP client required by Swoosh API client.
|
nil -> 587
|
||||||
# Swoosh supports Hackney, Req and Finch out of the box:
|
v -> String.to_integer(String.trim(v))
|
||||||
#
|
end
|
||||||
# config :swoosh, :api_client, Swoosh.ApiClient.Hackney
|
|
||||||
#
|
smtp_password_env =
|
||||||
# See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details.
|
case System.get_env("SMTP_PASSWORD") do
|
||||||
|
nil ->
|
||||||
|
case System.get_env("SMTP_PASSWORD_FILE") do
|
||||||
|
nil -> nil
|
||||||
|
path -> path |> File.read!() |> String.trim()
|
||||||
|
end
|
||||||
|
|
||||||
|
v ->
|
||||||
|
v
|
||||||
|
end
|
||||||
|
|
||||||
|
smtp_ssl_mode = System.get_env("SMTP_SSL", "tls")
|
||||||
|
|
||||||
|
# SMTP_VERIFY_PEER: set to true/1/yes to enable TLS certificate verification (recommended
|
||||||
|
# for public SMTP like Gmail/Mailgun). Default false for self-signed/internal certs.
|
||||||
|
smtp_verify_peer =
|
||||||
|
(System.get_env("SMTP_VERIFY_PEER", "false") |> String.downcase()) in ~w(true 1 yes)
|
||||||
|
|
||||||
|
config :mv, :smtp_verify_peer, smtp_verify_peer
|
||||||
|
|
||||||
|
verify_mode = if smtp_verify_peer, do: :verify_peer, else: :verify_none
|
||||||
|
|
||||||
|
smtp_opts =
|
||||||
|
Mv.Smtp.ConfigBuilder.build_opts(
|
||||||
|
host: String.trim(smtp_host_env),
|
||||||
|
port: smtp_port_env,
|
||||||
|
username: System.get_env("SMTP_USERNAME"),
|
||||||
|
password: smtp_password_env,
|
||||||
|
ssl_mode: smtp_ssl_mode,
|
||||||
|
verify_mode: verify_mode
|
||||||
|
)
|
||||||
|
|
||||||
|
config :mv, Mv.Mailer, smtp_opts
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -58,3 +58,7 @@ config :mv, :sql_sandbox, true
|
||||||
|
|
||||||
# Join form rate limit: low limit so tests can trigger rate limiting (e.g. 2 per minute)
|
# Join form rate limit: low limit so tests can trigger rate limiting (e.g. 2 per minute)
|
||||||
config :mv, :join_rate_limit, scale_ms: 60_000, limit: 2
|
config :mv, :join_rate_limit, scale_ms: 60_000, limit: 2
|
||||||
|
|
||||||
|
# Ash: silence "after_transaction hooks in surrounding transaction" warning when using
|
||||||
|
# Ecto sandbox (tests run in a transaction; create_member after_transaction is expected).
|
||||||
|
config :ash, warn_on_transaction_hooks?: false
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
- **Admin bootstrap:** In production, the Docker entrypoint runs migrate, then `Mv.Release.run_seeds/0` (bootstrap seeds; set `RUN_DEV_SEEDS=true` to also run dev seeds), then `seed_admin/0` from ENV, then the server. Password can be changed without redeploy via `bin/mv eval "Mv.Release.seed_admin()"`.
|
- **Admin bootstrap:** In production, the Docker entrypoint runs migrate, then `Mv.Release.run_seeds/0` (skips if admin user already exists unless `FORCE_SEEDS=true`; set `RUN_DEV_SEEDS=true` to also run dev seeds), then `seed_admin/0` from ENV, then the server. Password can be changed without redeploy via `bin/mv eval "Mv.Release.seed_admin()"`.
|
||||||
- **OIDC role sync:** Optional mapping from OIDC groups (e.g. from Authentik profile scope) to the Admin role. Users in the configured admin group get the Admin role on registration and on each sign-in.
|
- **OIDC role sync:** Optional mapping from OIDC groups (e.g. from Authentik profile scope) to the Admin role. Users in the configured admin group get the Admin role on registration and on each sign-in.
|
||||||
|
|
||||||
## Admin Bootstrap (Part A)
|
## Admin Bootstrap (Part A)
|
||||||
|
|
@ -10,13 +10,14 @@
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
- `RUN_DEV_SEEDS` – If set to `"true"`, `run_seeds/0` also runs dev seeds (members, groups, sample data). Otherwise only bootstrap seeds run.
|
- `RUN_DEV_SEEDS` – If set to `"true"`, `run_seeds/0` also runs dev seeds (members, groups, sample data). Otherwise only bootstrap seeds run.
|
||||||
|
- `FORCE_SEEDS` – If set to `"true"`, seeds are run even when the admin user already exists (e.g. after changing bootstrap data such as roles or custom fields). Otherwise seeds are skipped when bootstrap was already applied.
|
||||||
- `ADMIN_EMAIL` – Email of the admin user to create/update. If unset, seed_admin/0 does nothing.
|
- `ADMIN_EMAIL` – Email of the admin user to create/update. If unset, seed_admin/0 does nothing.
|
||||||
- `ADMIN_PASSWORD` – Password for the admin user. If unset (and no file), no new user is created; if a user with ADMIN_EMAIL already exists (e.g. OIDC-only), their role is set to Admin (no password change).
|
- `ADMIN_PASSWORD` – Password for the admin user. If unset (and no file), no new user is created; if a user with ADMIN_EMAIL already exists (e.g. OIDC-only), their role is set to Admin (no password change).
|
||||||
- `ADMIN_PASSWORD_FILE` – Path to a file containing the password (e.g. Docker secret).
|
- `ADMIN_PASSWORD_FILE` – Path to a file containing the password (e.g. Docker secret).
|
||||||
|
|
||||||
### Release Tasks
|
### Release Tasks
|
||||||
|
|
||||||
- `Mv.Release.run_seeds/0` – Runs bootstrap seeds (fee types, custom fields, roles, settings). If `RUN_DEV_SEEDS` env is `"true"`, also runs dev seeds (members, groups, sample data). Idempotent.
|
- `Mv.Release.run_seeds/0` – If the admin user already exists (bootstrap already applied), skips unless `FORCE_SEEDS=true`; otherwise runs bootstrap seeds (fee types, custom fields, roles, settings). If `RUN_DEV_SEEDS` env is `"true"`, also runs dev seeds (members, groups, sample data). Safe to call on every start.
|
||||||
- `Mv.Release.seed_admin/0` – Reads ADMIN_EMAIL and password from ADMIN_PASSWORD or ADMIN_PASSWORD_FILE. If both email and password are set: creates or updates the user with the Admin role. If only ADMIN_EMAIL is set: sets the Admin role on an existing user with that email (for OIDC-only admins); does not create a user. Idempotent.
|
- `Mv.Release.seed_admin/0` – Reads ADMIN_EMAIL and password from ADMIN_PASSWORD or ADMIN_PASSWORD_FILE. If both email and password are set: creates or updates the user with the Admin role. If only ADMIN_EMAIL is set: sets the Admin role on an existing user with that email (for OIDC-only admins); does not create a user. Idempotent.
|
||||||
|
|
||||||
### Entrypoint
|
### Entrypoint
|
||||||
|
|
@ -38,6 +39,7 @@
|
||||||
### Sign-in page (OIDC-only mode)
|
### Sign-in page (OIDC-only mode)
|
||||||
|
|
||||||
- `OIDC_ONLY` (or Settings → OIDC → "Only OIDC sign-in") – When set to true/1/yes and OIDC is configured, the sign-in page shows only the Single Sign-On button (password login is hidden). ENV takes precedence over Settings.
|
- `OIDC_ONLY` (or Settings → OIDC → "Only OIDC sign-in") – When set to true/1/yes and OIDC is configured, the sign-in page shows only the Single Sign-On button (password login is hidden). ENV takes precedence over Settings.
|
||||||
|
- **Redirect loop fix:** After an OIDC failure (e.g. provider down), the app redirects to `/sign-in?oidc_failed=1`. The plug `OidcOnlySignInRedirect` does not redirect that request back to OIDC, so the sign-in page is shown with the error (no endless redirect).
|
||||||
|
|
||||||
### Sync Logic
|
### Sync Logic
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -806,7 +806,7 @@ end
|
||||||
- **Senders migrated:** `SendNewUserConfirmationEmail`, `SendPasswordResetEmail` use layout + `Mv.Mailer.mail_from/0`.
|
- **Senders migrated:** `SendNewUserConfirmationEmail`, `SendPasswordResetEmail` use layout + `Mv.Mailer.mail_from/0`.
|
||||||
- **Cleanup:** Mix task `mix join_requests.cleanup_expired` hard-deletes JoinRequests in `pending_confirmation` with expired `confirmation_token_expires_at` (authorize?: false). For cron/Oban.
|
- **Cleanup:** Mix task `mix join_requests.cleanup_expired` hard-deletes JoinRequests in `pending_confirmation` with expired `confirmation_token_expires_at` (authorize?: false). For cron/Oban.
|
||||||
- **Gettext:** New email strings in default domain; German translations in de/LC_MESSAGES/default.po; English msgstr filled for email-related strings.
|
- **Gettext:** New email strings in default domain; German translations in de/LC_MESSAGES/default.po; English msgstr filled for email-related strings.
|
||||||
- **PR review follow-ups (Join confirmation):** Join confirmation email uses `Mailer.deliver/1` and returns `{:ok, email}` \| `{:error, reason}`; domain logs delivery errors but still returns `{:ok, request}` so the user sees success. Comment in `submit_join_request/2` clarifies that the raw token is hashed by `JoinRequest.Changes.SetConfirmationToken`. Cleanup task uses `Ash.bulk_destroy` and logs partial errors without halting. Layout uses assigns `app_name` and `locale` (from config/Gettext) instead of hardcoded "Mila" and `lang="de"`. Production `runtime.exs` sets `:mail_from` from ENV (`MAIL_FROM_NAME`, `MAIL_FROM_EMAIL`). Layout reference unified to `"layout.html"`; redundant `put_layout` removed from senders.
|
- **PR review follow-ups (Join confirmation):** Join confirmation email uses `Mailer.deliver/2` with `Mailer.smtp_config/0` (same config as test mail). On delivery failure the domain returns `{:error, :email_delivery_failed}` (logged via `Logger.error`), and the JoinLive shows an error message (no success UI). Comment in `submit_join_request/2` clarifies that the raw token is hashed by `JoinRequest.Changes.SetConfirmationToken`. Cleanup task uses `Ash.bulk_destroy` and logs partial errors without halting. Layout uses assigns `app_name` and `locale` (from config/Gettext) instead of hardcoded "Mila" and `lang="de"`. Production `runtime.exs` sets `:mail_from` from ENV (`MAIL_FROM_NAME`, `MAIL_FROM_EMAIL`). Layout reference unified to `"layout.html"`; redundant `put_layout` removed from senders.
|
||||||
- Tests: `join_request_test.exs`, `join_request_submit_email_test.exs`, `join_confirm_controller_test.exs` – all pass.
|
- Tests: `join_request_test.exs`, `join_request_submit_email_test.exs`, `join_confirm_controller_test.exs` – all pass.
|
||||||
|
|
||||||
**Subtask 3 – Admin: Join form settings (done):**
|
**Subtask 3 – Admin: Join form settings (done):**
|
||||||
|
|
|
||||||
|
|
@ -36,10 +36,10 @@
|
||||||
|
|
||||||
**Closed Issues:**
|
**Closed Issues:**
|
||||||
- ✅ [#171](https://git.local-it.org/local-it/mitgliederverwaltung/issues/171) - OIDC handling and linking (closed 2025-11-13)
|
- ✅ [#171](https://git.local-it.org/local-it/mitgliederverwaltung/issues/171) - OIDC handling and linking (closed 2025-11-13)
|
||||||
|
- ✅ [#146](https://git.local-it.org/local-it/mitgliederverwaltung/issues/146) - Translate "or" in the login screen — fixed via `MvWeb.AuthOverridesDE` locale-specific module (2026-03-13)
|
||||||
|
- ✅ [#144](https://git.local-it.org/local-it/mitgliederverwaltung/issues/144) - Add language switch dropdown to login screen — fixed locale selector bug with `Gettext.get_locale(MvWeb.Gettext)` (2026-03-13)
|
||||||
|
|
||||||
**Open Issues:**
|
**Open Issues:** (none remaining for Authentication UI)
|
||||||
- [#146](https://git.local-it.org/local-it/mitgliederverwaltung/issues/146) - Translate "or" in the login screen (Low)
|
|
||||||
- [#144](https://git.local-it.org/local-it/mitgliederverwaltung/issues/144) - Add language switch dropdown to login screen (Low)
|
|
||||||
|
|
||||||
**Current State:**
|
**Current State:**
|
||||||
- ✅ **Role-based access control (RBAC)** - Implemented (2026-01-08, PR #346, closes #345)
|
- ✅ **Role-based access control (RBAC)** - Implemented (2026-01-08, PR #346, closes #345)
|
||||||
|
|
@ -49,6 +49,11 @@
|
||||||
- ✅ **Page-level authorization** - LiveView page access control
|
- ✅ **Page-level authorization** - LiveView page access control
|
||||||
- ✅ **System role protection** - Critical roles cannot be deleted
|
- ✅ **System role protection** - Critical roles cannot be deleted
|
||||||
|
|
||||||
|
**Planned: OIDC-only mode (TDD, tests first):**
|
||||||
|
- Admin Settings: When OIDC-only is enabled, disable "Allow direct registration" toggle and show hint (tests in `GlobalSettingsLiveTest`).
|
||||||
|
- Backend: Reject password sign-in and `register_with_password` when OIDC-only (tests in `AuthControllerTest`, `Accounts`).
|
||||||
|
- GET `/sign-in` redirect to OIDC when OIDC-only and OIDC configured (tests in `AuthControllerTest`). Implementation to follow after tests.
|
||||||
|
|
||||||
**Missing Features:**
|
**Missing Features:**
|
||||||
- ❌ Password reset flow
|
- ❌ Password reset flow
|
||||||
- ❌ Email verification
|
- ❌ Email verification
|
||||||
|
|
@ -270,6 +275,9 @@
|
||||||
**Open Issues:**
|
**Open Issues:**
|
||||||
- [#186](https://git.local-it.org/local-it/mitgliederverwaltung/issues/186) - Create Architecture docs in Repo (S, Low priority)
|
- [#186](https://git.local-it.org/local-it/mitgliederverwaltung/issues/186) - Create Architecture docs in Repo (S, Low priority)
|
||||||
|
|
||||||
|
**Implemented Features:**
|
||||||
|
- ✅ **SMTP configuration** – Configure mail server via ENV (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_PASSWORD_FILE`, `SMTP_SSL`) and Admin Settings (UI), with ENV taking priority. Test email from Settings SMTP section. Production warning when SMTP is not configured. See [`docs/smtp-configuration-concept.md`](smtp-configuration-concept.md).
|
||||||
|
|
||||||
**Missing Features:**
|
**Missing Features:**
|
||||||
- ❌ Email templates configuration
|
- ❌ Email templates configuration
|
||||||
- ❌ System health dashboard
|
- ❌ System health dashboard
|
||||||
|
|
@ -287,6 +295,7 @@
|
||||||
- ✅ Swoosh mailer integration
|
- ✅ Swoosh mailer integration
|
||||||
- ✅ Email confirmation (via AshAuthentication)
|
- ✅ Email confirmation (via AshAuthentication)
|
||||||
- ✅ Password reset emails (via AshAuthentication)
|
- ✅ Password reset emails (via AshAuthentication)
|
||||||
|
- ✅ **SMTP configuration** via ENV and Admin Settings (see Admin Panel section)
|
||||||
- ⚠️ No member communication features
|
- ⚠️ No member communication features
|
||||||
|
|
||||||
**Missing Features:**
|
**Missing Features:**
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,7 @@
|
||||||
|
|
||||||
- **Placement:** Own section **"Onboarding / Join"** in global settings, **above** "Custom fields", **below** "Vereinsdaten" (club data).
|
- **Placement:** Own section **"Onboarding / Join"** in global settings, **above** "Custom fields", **below** "Vereinsdaten" (club data).
|
||||||
- **Join form enabled:** Checkbox (e.g. `join_form_enabled`). When set, the public `/join` page is active and the following config applies.
|
- **Join form enabled:** Checkbox (e.g. `join_form_enabled`). When set, the public `/join` page is active and the following config applies.
|
||||||
|
- **Copyable join link:** When the join form is enabled, a copyable full URL to the `/join` page is shown below the checkbox (above the field list), with a short hint so admins can share it with applicants.
|
||||||
- **Field selection:** From **all existing** member fields (from `Mv.Constants.member_fields()`) and **custom fields**, the admin selects which fields appear on the join form. Stored as a list/set of field identifiers (no separate table); display in settings as a simple list, e.g. **badges with X to remove** (similar to the groups overview). Adding fields: e.g. dropdown or modal to pick from remaining fields. Detailed UX for this subsection is to be specified in a **separate subtask**.
|
- **Field selection:** From **all existing** member fields (from `Mv.Constants.member_fields()`) and **custom fields**, the admin selects which fields appear on the join form. Stored as a list/set of field identifiers (no separate table); display in settings as a simple list, e.g. **badges with X to remove** (similar to the groups overview). Adding fields: e.g. dropdown or modal to pick from remaining fields. Detailed UX for this subsection is to be specified in a **separate subtask**.
|
||||||
- **Technically required fields:** The only field that must always be required for the join flow is **email**. All other fields can be optional or marked as required per admin choice; implementation should support a "required" flag per selected join-form field.
|
- **Technically required fields:** The only field that must always be required for the join flow is **email**. All other fields can be optional or marked as required per admin choice; implementation should support a "required" flag per selected join-form field.
|
||||||
- **Other:** Which entry paths are enabled, approval workflow (who can approve) – to be detailed in Step 2 and later specs.
|
- **Other:** Which entry paths are enabled, approval workflow (who can approve) – to be detailed in Step 2 and later specs.
|
||||||
|
|
@ -115,7 +116,7 @@ Implementation spec for Subtask 5.
|
||||||
#### Route and pages
|
#### Route and pages
|
||||||
|
|
||||||
- **List:** **`/join_requests`** – list of join requests. Filter by status (default or primary view: status `submitted`); optional view for "all" or "approved/rejected" for audit.
|
- **List:** **`/join_requests`** – list of join requests. Filter by status (default or primary view: status `submitted`); optional view for "all" or "approved/rejected" for audit.
|
||||||
- **Detail:** **`/join_requests/:id`** – single join request with all data (typed fields + `form_data`), actions Approve / Reject.
|
- **Detail:** **`/join_requests/:id`** – single join request. **Two blocks:** (1) **Applicant data** – all form fields (typed + `form_data`) merged and shown in join-form order; (2) **Status and review** – submitted_at, status, and when decided: approved_at/rejected_at, reviewed by. Actions Approve / Reject when status is `submitted`.
|
||||||
|
|
||||||
#### Backend (JoinRequest)
|
#### Backend (JoinRequest)
|
||||||
|
|
||||||
|
|
@ -195,7 +196,7 @@ Implementation spec for Subtask 5.
|
||||||
- **Pre-confirmation store:** **DB only.** Same JoinRequest resource; no ETS, no stateless token. Confirmation token stored as **hash** in DB; raw token only in email link. **24h** retention for `pending_confirmation`; **hard-delete** of expired records via scheduled job (e.g. Oban cron).
|
- **Pre-confirmation store:** **DB only.** Same JoinRequest resource; no ETS, no stateless token. Confirmation token stored as **hash** in DB; raw token only in email link. **24h** retention for `pending_confirmation`; **hard-delete** of expired records via scheduled job (e.g. Oban cron).
|
||||||
- **Confirmation route:** **`/confirm_join/:token`** so existing `starts_with?(path, "/confirm")` covers it.
|
- **Confirmation route:** **`/confirm_join/:token`** so existing `starts_with?(path, "/confirm")` covers it.
|
||||||
- **Public path for `/join`:** **Add `/join` explicitly** to the page-permission plug’s `public_path?/1` (e.g. in `CheckPagePermission`) so unauthenticated users can reach the join page.
|
- **Public path for `/join`:** **Add `/join` explicitly** to the page-permission 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**.
|
- **Approval outcome:** Admin-configurable. Default: approval creates Member only (no User). Optional "create User on approval" is **left for later**.
|
||||||
- **Rate limiting:** Honeypot + rate limiting from the start (e.g. Hammer.Plug).
|
- **Rate limiting:** Honeypot + rate limiting from the start (e.g. Hammer.Plug).
|
||||||
- **Settings:** Own section "Onboarding / Join" in global settings; `join_form_enabled` plus field selection; display as list/badges; detailed UX in a **separate subtask**.
|
- **Settings:** Own section "Onboarding / Join" in global settings; `join_form_enabled` plus field selection; display as list/badges; detailed UX in a **separate subtask**.
|
||||||
|
|
|
||||||
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] |
|
||||||
|
+------------------------------------------------------------------+
|
||||||
133
docs/smtp-configuration-concept.md
Normal file
133
docs/smtp-configuration-concept.md
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
# SMTP Configuration – Concept
|
||||||
|
|
||||||
|
**Status:** Implemented
|
||||||
|
**Last updated:** 2026-03-12
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Goal
|
||||||
|
|
||||||
|
Enable configurable SMTP for sending transactional emails (join confirmation, user confirmation, password reset). Configuration via **environment variables** and **Admin Settings** (database), with the same precedence pattern as OIDC and Vereinfacht: **ENV overrides Settings**. Include a **test email** action in Settings (button + recipient field) with clear success/error feedback.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Scope
|
||||||
|
|
||||||
|
- **In scope:** SMTP server configuration (host, port, credentials, TLS/SSL), sender identity (from-name, from-email), test email from Settings UI, warning when SMTP is not configured in production, specific error messages per failure category, graceful delivery errors in AshAuthentication senders.
|
||||||
|
- **Out of scope:** Separate adapters per email type; retry queues.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Configuration Sources
|
||||||
|
|
||||||
|
| Source | Priority | Use case |
|
||||||
|
|----------|----------|-----------------------------------|
|
||||||
|
| ENV | 1 | Production, Docker, 12-factor |
|
||||||
|
| Settings | 2 | Admin UI, dev without ENV |
|
||||||
|
|
||||||
|
When an ENV variable is set, the corresponding Settings field is read-only in the UI (with hint "Set by environment").
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. SMTP Parameters
|
||||||
|
|
||||||
|
| Parameter | ENV | Settings attribute | Notes |
|
||||||
|
|----------------|------------------------|---------------------|---------------------------------------------|
|
||||||
|
| Host | `SMTP_HOST` | `smtp_host` | e.g. `smtp.example.com` |
|
||||||
|
| Port | `SMTP_PORT` | `smtp_port` | Default 587 (TLS), 465 (SSL), 25 (plain) |
|
||||||
|
| Username | `SMTP_USERNAME` | `smtp_username` | Optional if no auth |
|
||||||
|
| Password | `SMTP_PASSWORD` | `smtp_password` | Sensitive, not shown when set |
|
||||||
|
| Password file | `SMTP_PASSWORD_FILE` | — | Docker/Secrets: path to file with password |
|
||||||
|
| TLS/SSL | `SMTP_SSL` | `smtp_ssl` | `tls` / `ssl` / `none` (default: tls) |
|
||||||
|
| Sender name | `MAIL_FROM_NAME` | `smtp_from_name` | Display name in "From" header (default: Mila)|
|
||||||
|
| Sender email | `MAIL_FROM_EMAIL` | `smtp_from_email` | Address in "From" header; must match SMTP user on most servers |
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
Support **SMTP_PASSWORD_FILE** (path to file containing the password), same pattern as `OIDC_CLIENT_SECRET_FILE` in `runtime.exs`. Read once at runtime; `SMTP_PASSWORD` ENV overrides file if both are set.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Behaviour When SMTP Is Not Configured
|
||||||
|
|
||||||
|
- **Dev/Test:** Keep current adapters (`Swoosh.Adapters.Local`, `Swoosh.Adapters.Test`). No change.
|
||||||
|
- **Production:** If neither ENV nor Settings provide SMTP (no host):
|
||||||
|
- Show a warning in the Settings UI.
|
||||||
|
- Delivery attempts silently fall back to the Local adapter (no crash).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Test Email (Settings UI)
|
||||||
|
|
||||||
|
- **Location:** SMTP / E-Mail section in Global Settings.
|
||||||
|
- **Elements:** Input for recipient, submit button inside a `phx-submit` form.
|
||||||
|
- **Behaviour:** Sends one email using current SMTP config and `mail_from/0`. Returns `{:ok, _}` or `{:error, classified_reason}`.
|
||||||
|
- **Error categories:** `:sender_rejected`, `:auth_failed`, `:recipient_rejected`, `:tls_failed`, `:connection_failed`, `{:smtp_error, message}` — each shows a specific human-readable message in the UI.
|
||||||
|
- **Permission:** Reuses existing Settings page authorization (admin).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Sender Identity (`mail_from`)
|
||||||
|
|
||||||
|
`Mv.Mailer.mail_from/0` returns `{name, email}`. Priority:
|
||||||
|
1. `MAIL_FROM_NAME` / `MAIL_FROM_EMAIL` ENV variables
|
||||||
|
2. `smtp_from_name` / `smtp_from_email` in Settings (DB)
|
||||||
|
3. Hardcoded defaults: `{"Mila", "noreply@example.com"}`
|
||||||
|
|
||||||
|
Provided by `Mv.Config.mail_from_name/0` and `Mv.Config.mail_from_email/0`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. TLS / SSL in OTP 27
|
||||||
|
|
||||||
|
OTP 26+ enforces `verify_peer` by default, which fails for self-signed or internal SMTP server certificates.
|
||||||
|
|
||||||
|
By default, TLS certificate verification is relaxed (`verify_none`) so self-signed or internal SMTP servers work. For public SMTP providers (Gmail, Mailgun, etc.) you can enable verification:
|
||||||
|
|
||||||
|
- **ENV (prod):** Set `SMTP_VERIFY_PEER=true` (or `1`/`yes`) when configuring SMTP via environment variables in `config/runtime.exs`. This sets `config :mv, :smtp_verify_peer` and is used for both boot-time and per-send config.
|
||||||
|
- **Default:** `false` (verify_none) for backward compatibility and internal/self-signed certs.
|
||||||
|
|
||||||
|
Verify mode is set in `tls_options` for port 587 (STARTTLS). For port 465 (implicit SSL), the initial connection is `ssl:connect`, so we also pass `sockopts: [verify: verify_mode]` so the SSL handshake uses the same mode. For 587 we must not pass `verify` in sockopts—gen_tcp is used first and rejects it (ArgumentError). The logic lives in `Mv.Smtp.ConfigBuilder.build_opts/1` (single source of truth), used by `config/runtime.exs` (boot) and `Mv.Mailer.smtp_config/0` (Settings-only).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- [x] Settings: attributes and UI for host, port, username, password, TLS/SSL, from-name, from-email.
|
||||||
|
- [x] Password from file: `SMTP_PASSWORD_FILE` supported in `runtime.exs`.
|
||||||
|
- [x] Mailer: Swoosh SMTP adapter configured from merged ENV + Settings when SMTP is configured.
|
||||||
|
- [x] Per-request SMTP config via `Mv.Mailer.smtp_config/0` for Settings-only scenarios.
|
||||||
|
- [x] TLS certificate validation relaxed for OTP 27 (tls_options for 587; sockopts with verify only for 465).
|
||||||
|
- [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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
@ -362,6 +362,12 @@ defmodule Mv.Accounts.User do
|
||||||
# Authorization Policies
|
# Authorization Policies
|
||||||
# Order matters: Most specific policies first, then general permission check
|
# Order matters: Most specific policies first, then general permission check
|
||||||
policies do
|
policies do
|
||||||
|
# When OIDC-only is active, password sign-in is forbidden (SSO only).
|
||||||
|
policy action(:sign_in_with_password) do
|
||||||
|
forbid_if Mv.Authorization.Checks.OidcOnlyActive
|
||||||
|
authorize_if always()
|
||||||
|
end
|
||||||
|
|
||||||
# AshAuthentication bypass (registration/login without actor)
|
# AshAuthentication bypass (registration/login without actor)
|
||||||
bypass AshAuthentication.Checks.AshAuthenticationInteraction do
|
bypass AshAuthentication.Checks.AshAuthenticationInteraction do
|
||||||
description "Allow AshAuthentication internal operations (registration, login)"
|
description "Allow AshAuthentication internal operations (registration, login)"
|
||||||
|
|
@ -405,6 +411,14 @@ defmodule Mv.Accounts.User do
|
||||||
where: [action_is([:register_with_password, :admin_set_password])],
|
where: [action_is([:register_with_password, :admin_set_password])],
|
||||||
message: "must have length of at least 8"
|
message: "must have length of at least 8"
|
||||||
|
|
||||||
|
# Block direct registration when disabled in global settings
|
||||||
|
validate {Mv.Accounts.User.Validations.RegistrationEnabled, []},
|
||||||
|
where: [action_is(:register_with_password)]
|
||||||
|
|
||||||
|
# Block password registration when OIDC-only mode is active
|
||||||
|
validate {Mv.Accounts.User.Validations.OidcOnlyBlocksPasswordRegistration, []},
|
||||||
|
where: [action_is(:register_with_password)]
|
||||||
|
|
||||||
# Email uniqueness check for all actions that change the email attribute
|
# Email uniqueness check for all actions that change the email attribute
|
||||||
# Validates that user email is not already used by another (unlinked) member
|
# Validates that user email is not already used by another (unlinked) member
|
||||||
validate Mv.Accounts.User.Validations.EmailNotUsedByOtherMember
|
validate Mv.Accounts.User.Validations.EmailNotUsedByOtherMember
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
defmodule Mv.Accounts.User.Validations.OidcOnlyBlocksPasswordRegistration do
|
||||||
|
@moduledoc """
|
||||||
|
Validation that blocks direct registration (register_with_password) when
|
||||||
|
OIDC-only mode is active. In OIDC-only mode, sign-in and registration are
|
||||||
|
only allowed via OIDC (SSO).
|
||||||
|
"""
|
||||||
|
use Ash.Resource.Validation
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def init(opts), do: {:ok, opts}
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def validate(_changeset, _opts, _context) do
|
||||||
|
if Mv.Config.oidc_only?() do
|
||||||
|
{:error,
|
||||||
|
field: :base,
|
||||||
|
message:
|
||||||
|
Gettext.dgettext(
|
||||||
|
MvWeb.Gettext,
|
||||||
|
"default",
|
||||||
|
"Registration with password is disabled when only OIDC sign-in is active."
|
||||||
|
)}
|
||||||
|
else
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
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
|
change Mv.Membership.JoinRequest.Changes.RejectRequest
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Internal: resend confirmation (new token) when user submits form again with same email.
|
||||||
|
# Called from domain with authorize?: false; not exposed to public.
|
||||||
|
update :regenerate_confirmation_token do
|
||||||
|
description "Set new confirmation token and expiry (resend flow)"
|
||||||
|
require_atomic? false
|
||||||
|
|
||||||
|
argument :confirmation_token, :string, allow_nil?: false
|
||||||
|
|
||||||
|
change Mv.Membership.JoinRequest.Changes.RegenerateConfirmationToken
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
policies do
|
policies do
|
||||||
|
|
@ -175,6 +186,11 @@ defmodule Mv.Membership.JoinRequest do
|
||||||
attribute :approved_at, :utc_datetime_usec
|
attribute :approved_at, :utc_datetime_usec
|
||||||
attribute :rejected_at, :utc_datetime_usec
|
attribute :rejected_at, :utc_datetime_usec
|
||||||
attribute :reviewed_by_user_id, :uuid
|
attribute :reviewed_by_user_id, :uuid
|
||||||
|
|
||||||
|
attribute :reviewed_by_display, :string do
|
||||||
|
description "Denormalized reviewer display (e.g. email) for UI without loading User"
|
||||||
|
end
|
||||||
|
|
||||||
attribute :source, :string
|
attribute :source, :string
|
||||||
|
|
||||||
create_timestamp :inserted_at
|
create_timestamp :inserted_at
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,13 @@ defmodule Mv.Membership.JoinRequest.Changes.ApproveRequest do
|
||||||
|
|
||||||
if current_status == :submitted do
|
if current_status == :submitted do
|
||||||
reviewed_by_id = Helpers.actor_id(context.actor)
|
reviewed_by_id = Helpers.actor_id(context.actor)
|
||||||
|
reviewed_by_display = Helpers.actor_email(context.actor)
|
||||||
|
|
||||||
changeset
|
changeset
|
||||||
|> Ash.Changeset.force_change_attribute(:status, :approved)
|
|> Ash.Changeset.force_change_attribute(:status, :approved)
|
||||||
|> Ash.Changeset.force_change_attribute(:approved_at, DateTime.utc_now())
|
|> Ash.Changeset.force_change_attribute(:approved_at, DateTime.utc_now())
|
||||||
|> Ash.Changeset.force_change_attribute(:reviewed_by_user_id, reviewed_by_id)
|
|> Ash.Changeset.force_change_attribute(:reviewed_by_user_id, reviewed_by_id)
|
||||||
|
|> Ash.Changeset.force_change_attribute(:reviewed_by_display, reviewed_by_display)
|
||||||
else
|
else
|
||||||
Ash.Changeset.add_error(changeset,
|
Ash.Changeset.add_error(changeset,
|
||||||
field: :status,
|
field: :status,
|
||||||
|
|
|
||||||
|
|
@ -16,4 +16,24 @@ defmodule Mv.Membership.JoinRequest.Changes.Helpers do
|
||||||
end
|
end
|
||||||
|
|
||||||
def actor_id(_), do: nil
|
def actor_id(_), do: nil
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Extracts the actor's email for display (e.g. reviewed_by_display).
|
||||||
|
|
||||||
|
Supports both atom and string keys for compatibility with different actor representations.
|
||||||
|
"""
|
||||||
|
@spec actor_email(term()) :: String.t() | nil
|
||||||
|
def actor_email(nil), do: nil
|
||||||
|
|
||||||
|
def actor_email(actor) when is_map(actor) do
|
||||||
|
raw = Map.get(actor, :email) || Map.get(actor, "email")
|
||||||
|
if is_nil(raw), do: nil, else: actor_email_string(raw)
|
||||||
|
end
|
||||||
|
|
||||||
|
def actor_email(_), do: nil
|
||||||
|
|
||||||
|
defp actor_email_string(raw) do
|
||||||
|
s = raw |> to_string() |> String.trim()
|
||||||
|
if s == "", do: nil, else: s
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -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
|
if current_status == :submitted do
|
||||||
reviewed_by_id = Helpers.actor_id(context.actor)
|
reviewed_by_id = Helpers.actor_id(context.actor)
|
||||||
|
reviewed_by_display = Helpers.actor_email(context.actor)
|
||||||
|
|
||||||
changeset
|
changeset
|
||||||
|> Ash.Changeset.force_change_attribute(:status, :rejected)
|
|> Ash.Changeset.force_change_attribute(:status, :rejected)
|
||||||
|> Ash.Changeset.force_change_attribute(:rejected_at, DateTime.utc_now())
|
|> Ash.Changeset.force_change_attribute(:rejected_at, DateTime.utc_now())
|
||||||
|> Ash.Changeset.force_change_attribute(:reviewed_by_user_id, reviewed_by_id)
|
|> Ash.Changeset.force_change_attribute(:reviewed_by_user_id, reviewed_by_id)
|
||||||
|
|> Ash.Changeset.force_change_attribute(:reviewed_by_display, reviewed_by_display)
|
||||||
else
|
else
|
||||||
Ash.Changeset.add_error(changeset,
|
Ash.Changeset.add_error(changeset,
|
||||||
field: :status,
|
field: :status,
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,10 @@ defmodule Mv.Membership do
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
import Ash.Expr
|
import Ash.Expr
|
||||||
alias Ash.Error.Query.NotFound, as: NotFoundError
|
alias Ash.Error.Query.NotFound, as: NotFoundError
|
||||||
|
alias Mv.Helpers.SystemActor
|
||||||
alias Mv.Membership.JoinRequest
|
alias Mv.Membership.JoinRequest
|
||||||
alias MvWeb.Emails.JoinConfirmationEmail
|
alias Mv.Membership.Member
|
||||||
|
alias Mv.Membership.SettingsCache
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
admin do
|
admin do
|
||||||
|
|
@ -114,10 +116,16 @@ defmodule Mv.Membership do
|
||||||
|
|
||||||
"""
|
"""
|
||||||
def get_settings do
|
def get_settings do
|
||||||
# Try to get the first (and only) settings record
|
case Process.whereis(SettingsCache) do
|
||||||
|
nil -> get_settings_uncached()
|
||||||
|
_pid -> SettingsCache.get()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
def get_settings_uncached do
|
||||||
case Ash.read_one(Mv.Membership.Setting, domain: __MODULE__) do
|
case Ash.read_one(Mv.Membership.Setting, domain: __MODULE__) do
|
||||||
{:ok, nil} ->
|
{:ok, nil} ->
|
||||||
# No settings exist - create as fallback (should normally be created via seed script)
|
|
||||||
default_club_name = System.get_env("ASSOCIATION_NAME") || "Club Name"
|
default_club_name = System.get_env("ASSOCIATION_NAME") || "Club Name"
|
||||||
|
|
||||||
Mv.Membership.Setting
|
Mv.Membership.Setting
|
||||||
|
|
@ -158,9 +166,16 @@ defmodule Mv.Membership do
|
||||||
|
|
||||||
"""
|
"""
|
||||||
def update_settings(settings, attrs) do
|
def update_settings(settings, attrs) do
|
||||||
settings
|
case settings
|
||||||
|> Ash.Changeset.for_update(:update, attrs)
|
|> Ash.Changeset.for_update(:update, attrs)
|
||||||
|> Ash.update(domain: __MODULE__)
|
|> Ash.update(domain: __MODULE__) do
|
||||||
|
{:ok, _updated} = result ->
|
||||||
|
SettingsCache.invalidate()
|
||||||
|
result
|
||||||
|
|
||||||
|
error ->
|
||||||
|
error
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
@ -224,11 +239,18 @@ defmodule Mv.Membership do
|
||||||
|
|
||||||
"""
|
"""
|
||||||
def update_member_field_visibility(settings, visibility_config) do
|
def update_member_field_visibility(settings, visibility_config) do
|
||||||
settings
|
case settings
|
||||||
|> Ash.Changeset.for_update(:update_member_field_visibility, %{
|
|> Ash.Changeset.for_update(:update_member_field_visibility, %{
|
||||||
member_field_visibility: visibility_config
|
member_field_visibility: visibility_config
|
||||||
})
|
})
|
||||||
|> Ash.update(domain: __MODULE__)
|
|> Ash.update(domain: __MODULE__) do
|
||||||
|
{:ok, _} = result ->
|
||||||
|
SettingsCache.invalidate()
|
||||||
|
result
|
||||||
|
|
||||||
|
error ->
|
||||||
|
error
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
@ -261,12 +283,19 @@ defmodule Mv.Membership do
|
||||||
field: field,
|
field: field,
|
||||||
show_in_overview: show_in_overview
|
show_in_overview: show_in_overview
|
||||||
) do
|
) do
|
||||||
settings
|
case settings
|
||||||
|> Ash.Changeset.new()
|
|> Ash.Changeset.new()
|
||||||
|> Ash.Changeset.set_argument(:field, field)
|
|> Ash.Changeset.set_argument(:field, field)
|
||||||
|> Ash.Changeset.set_argument(:show_in_overview, show_in_overview)
|
|> Ash.Changeset.set_argument(:show_in_overview, show_in_overview)
|
||||||
|> Ash.Changeset.for_update(:update_single_member_field_visibility, %{})
|
|> Ash.Changeset.for_update(:update_single_member_field_visibility, %{})
|
||||||
|> Ash.update(domain: __MODULE__)
|
|> Ash.update(domain: __MODULE__) do
|
||||||
|
{:ok, _} = result ->
|
||||||
|
SettingsCache.invalidate()
|
||||||
|
result
|
||||||
|
|
||||||
|
error ->
|
||||||
|
error
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
@ -300,13 +329,20 @@ defmodule Mv.Membership do
|
||||||
show_in_overview: show_in_overview,
|
show_in_overview: show_in_overview,
|
||||||
required: required
|
required: required
|
||||||
) do
|
) do
|
||||||
settings
|
case settings
|
||||||
|> Ash.Changeset.new()
|
|> Ash.Changeset.new()
|
||||||
|> Ash.Changeset.set_argument(:field, field)
|
|> Ash.Changeset.set_argument(:field, field)
|
||||||
|> Ash.Changeset.set_argument(:show_in_overview, show_in_overview)
|
|> Ash.Changeset.set_argument(:show_in_overview, show_in_overview)
|
||||||
|> Ash.Changeset.set_argument(:required, required)
|
|> Ash.Changeset.set_argument(:required, required)
|
||||||
|> Ash.Changeset.for_update(:update_single_member_field, %{})
|
|> Ash.Changeset.for_update(:update_single_member_field, %{})
|
||||||
|> Ash.update(domain: __MODULE__)
|
|> Ash.update(domain: __MODULE__) do
|
||||||
|
{:ok, _} = result ->
|
||||||
|
SettingsCache.invalidate()
|
||||||
|
result
|
||||||
|
|
||||||
|
error ->
|
||||||
|
error
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
@ -364,15 +400,131 @@ defmodule Mv.Membership do
|
||||||
- `:actor` - Must be nil for public submit (policy allows only unauthenticated).
|
- `:actor` - Must be nil for public submit (policy allows only unauthenticated).
|
||||||
|
|
||||||
## Returns
|
## Returns
|
||||||
- `{:ok, request}` - Created JoinRequest in status pending_confirmation
|
- `{:ok, request}` - Created JoinRequest in status pending_confirmation, email sent
|
||||||
|
- `{:ok, :notified_already_member}` - Email already a member; notice sent by email only (no request created)
|
||||||
|
- `{:ok, :notified_already_pending}` - Email already has pending/submitted request; notice or resend sent by email only
|
||||||
|
- `{:error, :email_delivery_failed}` - Request created but confirmation email could not be sent (logged)
|
||||||
- `{:error, error}` - Validation or authorization error
|
- `{:error, error}` - Validation or authorization error
|
||||||
"""
|
"""
|
||||||
def submit_join_request(attrs, opts \\ []) do
|
def submit_join_request(attrs, opts \\ []) do
|
||||||
actor = Keyword.get(opts, :actor)
|
actor = Keyword.get(opts, :actor)
|
||||||
token = Map.get(attrs, :confirmation_token) || generate_confirmation_token()
|
email = normalize_submit_email(attrs)
|
||||||
|
|
||||||
# Raw token is passed to the submit action; JoinRequest.Changes.SetConfirmationToken
|
pending =
|
||||||
# hashes it before persist. Only the hash is stored; the raw token is sent in the email link.
|
if email != nil and email != "", do: pending_join_request_with_email(email), else: nil
|
||||||
|
|
||||||
|
cond do
|
||||||
|
email != nil and email != "" and member_exists_with_email?(email) ->
|
||||||
|
send_already_member_and_return(email)
|
||||||
|
|
||||||
|
pending != nil ->
|
||||||
|
handle_already_pending(email, pending)
|
||||||
|
|
||||||
|
true ->
|
||||||
|
do_create_join_request(attrs, actor)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_submit_email(attrs) do
|
||||||
|
raw = attrs["email"] || attrs[:email]
|
||||||
|
if is_binary(raw), do: String.trim(raw), else: nil
|
||||||
|
end
|
||||||
|
|
||||||
|
defp member_exists_with_email?(email) when is_binary(email) do
|
||||||
|
system_actor = SystemActor.get_system_actor()
|
||||||
|
opts = [actor: system_actor, domain: __MODULE__]
|
||||||
|
|
||||||
|
case Ash.get(Member, %{email: email}, opts) do
|
||||||
|
{:ok, _member} -> true
|
||||||
|
_ -> false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp member_exists_with_email?(_), do: false
|
||||||
|
|
||||||
|
defp pending_join_request_with_email(email) when is_binary(email) do
|
||||||
|
system_actor = SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
query =
|
||||||
|
JoinRequest
|
||||||
|
|> Ash.Query.filter(expr(email == ^email and status in [:pending_confirmation, :submitted]))
|
||||||
|
|> Ash.Query.sort(inserted_at: :desc)
|
||||||
|
|> Ash.Query.limit(1)
|
||||||
|
|
||||||
|
case Ash.read_one(query, actor: system_actor, domain: __MODULE__) do
|
||||||
|
{:ok, request} -> request
|
||||||
|
_ -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp pending_join_request_with_email(_), do: nil
|
||||||
|
|
||||||
|
defp join_notifier do
|
||||||
|
Application.get_env(:mv, :join_notifier, MvWeb.JoinNotifierImpl)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp send_already_member_and_return(email) do
|
||||||
|
case join_notifier().send_already_member(email) do
|
||||||
|
{:ok, _} ->
|
||||||
|
:ok
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.error("Join already-member email failed for #{email}: #{inspect(reason)}")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Delay is applied by the caller (e.g. JoinLive) to avoid blocking the process.
|
||||||
|
{:ok, :notified_already_member}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp handle_already_pending(email, existing) do
|
||||||
|
if existing.status == :pending_confirmation do
|
||||||
|
resend_confirmation_to_pending(email, existing)
|
||||||
|
else
|
||||||
|
send_already_pending_and_return(email)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp resend_confirmation_to_pending(email, request) do
|
||||||
|
new_token = generate_confirmation_token()
|
||||||
|
|
||||||
|
case request
|
||||||
|
|> Ash.Changeset.for_update(:regenerate_confirmation_token, %{
|
||||||
|
confirmation_token: new_token
|
||||||
|
})
|
||||||
|
|> Ash.update(domain: __MODULE__, authorize?: false) do
|
||||||
|
{:ok, _updated} ->
|
||||||
|
case join_notifier().send_confirmation(email, new_token, resend: true) do
|
||||||
|
{:ok, _} ->
|
||||||
|
:ok
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.error("Join resend confirmation email failed for #{email}: #{inspect(reason)}")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Delay is applied by the caller (e.g. JoinLive) to avoid blocking the process.
|
||||||
|
{:ok, :notified_already_pending}
|
||||||
|
|
||||||
|
{:error, _} ->
|
||||||
|
# Fallback: do not create duplicate; send generic pending email
|
||||||
|
send_already_pending_and_return(email)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp send_already_pending_and_return(email) do
|
||||||
|
case join_notifier().send_already_pending(email) do
|
||||||
|
{:ok, _} ->
|
||||||
|
:ok
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.error("Join already-pending email failed for #{email}: #{inspect(reason)}")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Delay is applied by the caller (e.g. JoinLive) to avoid blocking the process.
|
||||||
|
{:ok, :notified_already_pending}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_create_join_request(attrs, actor) do
|
||||||
|
token = Map.get(attrs, :confirmation_token) || generate_confirmation_token()
|
||||||
attrs_with_token = Map.put(attrs, :confirmation_token, token)
|
attrs_with_token = Map.put(attrs, :confirmation_token, token)
|
||||||
|
|
||||||
case Ash.create(JoinRequest, attrs_with_token,
|
case Ash.create(JoinRequest, attrs_with_token,
|
||||||
|
|
@ -381,8 +533,9 @@ defmodule Mv.Membership do
|
||||||
domain: __MODULE__
|
domain: __MODULE__
|
||||||
) do
|
) do
|
||||||
{:ok, request} ->
|
{:ok, request} ->
|
||||||
case JoinConfirmationEmail.send(request.email, token) do
|
case join_notifier().send_confirmation(request.email, token, []) do
|
||||||
{:ok, _email} ->
|
{:ok, _email} ->
|
||||||
|
# Delay is applied by the caller (e.g. JoinLive) to avoid blocking the process.
|
||||||
{:ok, request}
|
{:ok, request}
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
|
|
@ -390,8 +543,7 @@ defmodule Mv.Membership do
|
||||||
"Join confirmation email failed for #{request.email}: #{inspect(reason)}"
|
"Join confirmation email failed for #{request.email}: #{inspect(reason)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Request was created; return success so the user sees the confirmation message
|
{:error, :email_delivery_failed}
|
||||||
{:ok, request}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
error ->
|
error ->
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ defmodule Mv.Membership.Setting do
|
||||||
(e.g., `%{"first_name" => true, "last_name" => true}`). Email is always required; other fields default to optional.
|
(e.g., `%{"first_name" => true, "last_name" => true}`). Email is always required; other fields default to optional.
|
||||||
- `include_joining_cycle` - Whether to include the joining cycle in membership fee generation (default: true)
|
- `include_joining_cycle` - Whether to include the joining cycle in membership fee generation (default: true)
|
||||||
- `default_membership_fee_type_id` - Default membership fee type for new members (optional)
|
- `default_membership_fee_type_id` - Default membership fee type for new members (optional)
|
||||||
|
- `registration_enabled` - Whether direct registration via /register is allowed (default: true)
|
||||||
- `join_form_enabled` - Whether the public /join page is active (default: false)
|
- `join_form_enabled` - Whether the public /join page is active (default: false)
|
||||||
- `join_form_field_ids` - Ordered list of field IDs shown on the join form. Each entry is
|
- `join_form_field_ids` - Ordered list of field IDs shown on the join form. Each entry is
|
||||||
either a member field name string (e.g. "email") or a custom field UUID. Email is always
|
either a member field name string (e.g. "email") or a custom field UUID. Email is always
|
||||||
|
|
@ -56,14 +57,20 @@ defmodule Mv.Membership.Setting do
|
||||||
# Update membership fee settings
|
# Update membership fee settings
|
||||||
{:ok, updated} = Mv.Membership.update_settings(settings, %{include_joining_cycle: false})
|
{:ok, updated} = Mv.Membership.update_settings(settings, %{include_joining_cycle: false})
|
||||||
"""
|
"""
|
||||||
|
# primary_read_warning?: false — We use a custom read prepare that selects only public
|
||||||
|
# attributes and explicitly excludes smtp_password. Ash warns when the primary read does
|
||||||
|
# not load all attributes; we intentionally omit the password for security.
|
||||||
use Ash.Resource,
|
use Ash.Resource,
|
||||||
domain: Mv.Membership,
|
domain: Mv.Membership,
|
||||||
data_layer: AshPostgres.DataLayer
|
data_layer: AshPostgres.DataLayer,
|
||||||
|
primary_read_warning?: false
|
||||||
|
|
||||||
# Used in join_form_field_ids validation (compile-time to avoid recompiling regex and list on every validation)
|
# Used in join_form_field_ids validation (compile-time to avoid recompiling regex and list on every validation)
|
||||||
@uuid_pattern ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
@uuid_pattern ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||||
@valid_join_form_member_fields Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
@valid_join_form_member_fields Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
||||||
|
|
||||||
|
alias Ash.Resource.Info, as: ResourceInfo
|
||||||
|
|
||||||
postgres do
|
postgres do
|
||||||
table "settings"
|
table "settings"
|
||||||
repo Mv.Repo
|
repo Mv.Repo
|
||||||
|
|
@ -73,8 +80,27 @@ defmodule Mv.Membership.Setting do
|
||||||
description "Global application settings (singleton resource)"
|
description "Global application settings (singleton resource)"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Attributes excluded from the default read (sensitive data). Same pattern as smtp_password:
|
||||||
|
# read only via explicit select when needed; never loaded into default get_settings().
|
||||||
|
@excluded_from_read [:smtp_password, :oidc_client_secret]
|
||||||
|
|
||||||
actions do
|
actions do
|
||||||
defaults [:read]
|
read :read do
|
||||||
|
primary? true
|
||||||
|
|
||||||
|
# Exclude sensitive attributes (e.g. smtp_password) from default reads. Config reads
|
||||||
|
# them via explicit select when needed. Uses all attribute names minus excluded so
|
||||||
|
# the list stays correct when new attributes are added to the resource.
|
||||||
|
prepare fn query, _context ->
|
||||||
|
select_attrs =
|
||||||
|
__MODULE__
|
||||||
|
|> ResourceInfo.attribute_names()
|
||||||
|
|> MapSet.to_list()
|
||||||
|
|> Kernel.--(@excluded_from_read)
|
||||||
|
|
||||||
|
Ash.Query.select(query, select_attrs)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Internal create action - not exposed via code interface
|
# Internal create action - not exposed via code interface
|
||||||
# Used only as fallback in get_settings/0 if settings don't exist
|
# Used only as fallback in get_settings/0 if settings don't exist
|
||||||
|
|
@ -97,6 +123,14 @@ defmodule Mv.Membership.Setting do
|
||||||
:oidc_admin_group_name,
|
:oidc_admin_group_name,
|
||||||
:oidc_groups_claim,
|
:oidc_groups_claim,
|
||||||
:oidc_only,
|
:oidc_only,
|
||||||
|
:smtp_host,
|
||||||
|
:smtp_port,
|
||||||
|
:smtp_username,
|
||||||
|
:smtp_password,
|
||||||
|
:smtp_ssl,
|
||||||
|
:smtp_from_name,
|
||||||
|
:smtp_from_email,
|
||||||
|
:registration_enabled,
|
||||||
:join_form_enabled,
|
:join_form_enabled,
|
||||||
:join_form_field_ids,
|
:join_form_field_ids,
|
||||||
:join_form_field_required
|
:join_form_field_required
|
||||||
|
|
@ -126,6 +160,14 @@ defmodule Mv.Membership.Setting do
|
||||||
:oidc_admin_group_name,
|
:oidc_admin_group_name,
|
||||||
:oidc_groups_claim,
|
:oidc_groups_claim,
|
||||||
:oidc_only,
|
:oidc_only,
|
||||||
|
:smtp_host,
|
||||||
|
:smtp_port,
|
||||||
|
:smtp_username,
|
||||||
|
:smtp_password,
|
||||||
|
:smtp_ssl,
|
||||||
|
:smtp_from_name,
|
||||||
|
:smtp_from_email,
|
||||||
|
:registration_enabled,
|
||||||
:join_form_enabled,
|
:join_form_enabled,
|
||||||
:join_form_field_ids,
|
:join_form_field_ids,
|
||||||
:join_form_field_required
|
:join_form_field_required
|
||||||
|
|
@ -429,6 +471,61 @@ defmodule Mv.Membership.Setting do
|
||||||
description "When true and OIDC is configured, sign-in shows only OIDC (password login hidden)"
|
description "When true and OIDC is configured, sign-in shows only OIDC (password login hidden)"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# SMTP configuration (can be overridden by ENV)
|
||||||
|
attribute :smtp_host, :string do
|
||||||
|
allow_nil? true
|
||||||
|
public? true
|
||||||
|
description "SMTP server hostname (e.g. smtp.example.com)"
|
||||||
|
end
|
||||||
|
|
||||||
|
attribute :smtp_port, :integer do
|
||||||
|
allow_nil? true
|
||||||
|
public? true
|
||||||
|
description "SMTP server port (e.g. 587 for TLS, 465 for SSL, 25 for plain)"
|
||||||
|
end
|
||||||
|
|
||||||
|
attribute :smtp_username, :string do
|
||||||
|
allow_nil? true
|
||||||
|
public? true
|
||||||
|
description "SMTP authentication username"
|
||||||
|
end
|
||||||
|
|
||||||
|
attribute :smtp_password, :string do
|
||||||
|
allow_nil? true
|
||||||
|
public? false
|
||||||
|
description "SMTP authentication password (sensitive)"
|
||||||
|
sensitive? true
|
||||||
|
end
|
||||||
|
|
||||||
|
attribute :smtp_ssl, :string do
|
||||||
|
allow_nil? true
|
||||||
|
public? true
|
||||||
|
description "SMTP TLS/SSL mode: 'tls', 'ssl', or 'none'"
|
||||||
|
end
|
||||||
|
|
||||||
|
attribute :smtp_from_name, :string do
|
||||||
|
allow_nil? true
|
||||||
|
public? true
|
||||||
|
|
||||||
|
description "Display name for the transactional email sender (e.g. 'Mila'). Overrides MAIL_FROM_NAME env."
|
||||||
|
end
|
||||||
|
|
||||||
|
attribute :smtp_from_email, :string do
|
||||||
|
allow_nil? true
|
||||||
|
public? true
|
||||||
|
|
||||||
|
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
|
# Join form (Beitrittsformular) settings
|
||||||
attribute :join_form_enabled, :boolean do
|
attribute :join_form_enabled, :boolean do
|
||||||
allow_nil? false
|
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
|
||||||
|
|
@ -16,6 +16,8 @@ defmodule Mv.Accounts.User.Senders.SendNewUserConfirmationEmail do
|
||||||
import Swoosh.Email
|
import Swoosh.Email
|
||||||
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
|
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
alias Mv.Mailer
|
alias Mv.Mailer
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
@ -30,7 +32,8 @@ defmodule Mv.Accounts.User.Senders.SendNewUserConfirmationEmail do
|
||||||
- `_opts` - Additional options (unused)
|
- `_opts` - Additional options (unused)
|
||||||
|
|
||||||
## Returns
|
## Returns
|
||||||
The Swoosh.Email delivery result from `Mailer.deliver!/1`.
|
`:ok` always. Delivery errors are logged and not re-raised so they do not
|
||||||
|
crash the caller process (AshAuthentication ignores the return value).
|
||||||
"""
|
"""
|
||||||
@impl true
|
@impl true
|
||||||
def send(user, token, _) do
|
def send(user, token, _) do
|
||||||
|
|
@ -44,12 +47,24 @@ defmodule Mv.Accounts.User.Senders.SendNewUserConfirmationEmail do
|
||||||
locale: Gettext.get_locale(MvWeb.Gettext)
|
locale: Gettext.get_locale(MvWeb.Gettext)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
email =
|
||||||
new()
|
new()
|
||||||
|> from(Mailer.mail_from())
|
|> from(Mailer.mail_from())
|
||||||
|> to(to_string(user.email))
|
|> to(to_string(user.email))
|
||||||
|> subject(subject)
|
|> subject(subject)
|
||||||
|> put_view(MvWeb.EmailsView)
|
|> put_view(MvWeb.EmailsView)
|
||||||
|> render_body("user_confirmation.html", assigns)
|
|> render_body("user_confirmation.html", assigns)
|
||||||
|> Mailer.deliver!()
|
|
||||||
|
case Mailer.deliver(email) do
|
||||||
|
{:ok, _} ->
|
||||||
|
:ok
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.error(
|
||||||
|
"Failed to send user confirmation email to #{user.email}: #{inspect(reason)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ defmodule Mv.Accounts.User.Senders.SendPasswordResetEmail do
|
||||||
import Swoosh.Email
|
import Swoosh.Email
|
||||||
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
|
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
alias Mv.Mailer
|
alias Mv.Mailer
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
@ -30,7 +32,8 @@ defmodule Mv.Accounts.User.Senders.SendPasswordResetEmail do
|
||||||
- `_opts` - Additional options (unused)
|
- `_opts` - Additional options (unused)
|
||||||
|
|
||||||
## Returns
|
## Returns
|
||||||
The Swoosh.Email delivery result from `Mailer.deliver!/1`.
|
`:ok` always. Delivery errors are logged and not re-raised so they do not
|
||||||
|
crash the caller process (AshAuthentication ignores the return value).
|
||||||
"""
|
"""
|
||||||
@impl true
|
@impl true
|
||||||
def send(user, token, _) do
|
def send(user, token, _) do
|
||||||
|
|
@ -44,12 +47,21 @@ defmodule Mv.Accounts.User.Senders.SendPasswordResetEmail do
|
||||||
locale: Gettext.get_locale(MvWeb.Gettext)
|
locale: Gettext.get_locale(MvWeb.Gettext)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
email =
|
||||||
new()
|
new()
|
||||||
|> from(Mailer.mail_from())
|
|> from(Mailer.mail_from())
|
||||||
|> to(to_string(user.email))
|
|> to(to_string(user.email))
|
||||||
|> subject(subject)
|
|> subject(subject)
|
||||||
|> put_view(MvWeb.EmailsView)
|
|> put_view(MvWeb.EmailsView)
|
||||||
|> render_body("password_reset.html", assigns)
|
|> render_body("password_reset.html", assigns)
|
||||||
|> Mailer.deliver!()
|
|
||||||
|
case Mailer.deliver(email) do
|
||||||
|
{:ok, _} ->
|
||||||
|
:ok
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.error("Failed to send password reset email to #{user.email}: #{inspect(reason)}")
|
||||||
|
:ok
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ defmodule Mv.Application do
|
||||||
use Application
|
use Application
|
||||||
|
|
||||||
alias Mv.Helpers.SystemActor
|
alias Mv.Helpers.SystemActor
|
||||||
|
alias Mv.Membership.SettingsCache
|
||||||
alias Mv.Repo
|
alias Mv.Repo
|
||||||
alias Mv.Vereinfacht.SyncFlash
|
alias Mv.Vereinfacht.SyncFlash
|
||||||
alias MvWeb.Endpoint
|
alias MvWeb.Endpoint
|
||||||
|
|
@ -16,9 +17,17 @@ defmodule Mv.Application do
|
||||||
def start(_type, _args) do
|
def start(_type, _args) do
|
||||||
SyncFlash.create_table!()
|
SyncFlash.create_table!()
|
||||||
|
|
||||||
children = [
|
# SettingsCache not started in test so get_settings runs in the test process (Ecto Sandbox).
|
||||||
|
cache_children =
|
||||||
|
if Application.get_env(:mv, :environment) == :test, do: [], else: [SettingsCache]
|
||||||
|
|
||||||
|
children =
|
||||||
|
[
|
||||||
Telemetry,
|
Telemetry,
|
||||||
Repo,
|
Repo
|
||||||
|
] ++
|
||||||
|
cache_children ++
|
||||||
|
[
|
||||||
{JoinRateLimit, [clean_period: :timer.minutes(1)]},
|
{JoinRateLimit, [clean_period: :timer.minutes(1)]},
|
||||||
{Task.Supervisor, name: Mv.TaskSupervisor},
|
{Task.Supervisor, name: Mv.TaskSupervisor},
|
||||||
{DNSCluster, query: Application.get_env(:mv, :dns_cluster_query) || :ignore},
|
{DNSCluster, query: Application.get_env(:mv, :dns_cluster_query) || :ignore},
|
||||||
|
|
|
||||||
16
lib/mv/authorization/checks/oidc_only_active.ex
Normal file
16
lib/mv/authorization/checks/oidc_only_active.ex
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
defmodule Mv.Authorization.Checks.OidcOnlyActive do
|
||||||
|
@moduledoc """
|
||||||
|
Policy check: true when OIDC-only mode is active (Config.oidc_only?()).
|
||||||
|
|
||||||
|
Used to forbid password sign-in when only OIDC (SSO) sign-in is allowed.
|
||||||
|
"""
|
||||||
|
use Ash.Policy.SimpleCheck
|
||||||
|
|
||||||
|
alias Mv.Config
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def describe(_opts), do: "OIDC-only mode is active"
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def match?(_actor, _context, _opts), do: Config.oidc_only?()
|
||||||
|
end
|
||||||
227
lib/mv/config.ex
227
lib/mv/config.ex
|
|
@ -362,26 +362,41 @@ defmodule Mv.Config do
|
||||||
@doc """
|
@doc """
|
||||||
Returns the OIDC client secret.
|
Returns the OIDC client secret.
|
||||||
In production, uses the value from config :mv, :oidc (set by runtime.exs from OIDC_CLIENT_SECRET or OIDC_CLIENT_SECRET_FILE).
|
In production, uses the value from config :mv, :oidc (set by runtime.exs from OIDC_CLIENT_SECRET or OIDC_CLIENT_SECRET_FILE).
|
||||||
Otherwise ENV OIDC_CLIENT_SECRET, then Settings.
|
Otherwise ENV OIDC_CLIENT_SECRET, then Settings (read via explicit select; not in default get_settings).
|
||||||
"""
|
"""
|
||||||
@spec oidc_client_secret() :: String.t() | nil
|
@spec oidc_client_secret() :: String.t() | nil
|
||||||
def oidc_client_secret do
|
def oidc_client_secret do
|
||||||
case Application.get_env(:mv, :oidc) do
|
case Application.get_env(:mv, :oidc) do
|
||||||
oidc when is_list(oidc) -> oidc_client_secret_from_config(Keyword.get(oidc, :client_secret))
|
oidc when is_list(oidc) -> oidc_client_secret_from_config(Keyword.get(oidc, :client_secret))
|
||||||
_ -> env_or_setting("OIDC_CLIENT_SECRET", :oidc_client_secret)
|
_ -> oidc_client_secret_from_env_or_settings()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns whether the OIDC client secret is set in Settings (for UI badge). Does not expose the value.
|
||||||
|
"""
|
||||||
|
@spec oidc_client_secret_set?() :: boolean()
|
||||||
|
def oidc_client_secret_set? do
|
||||||
|
present?(get_oidc_client_secret_from_settings())
|
||||||
|
end
|
||||||
|
|
||||||
defp oidc_client_secret_from_config(nil),
|
defp oidc_client_secret_from_config(nil),
|
||||||
do: env_or_setting("OIDC_CLIENT_SECRET", :oidc_client_secret)
|
do: oidc_client_secret_from_env_or_settings()
|
||||||
|
|
||||||
defp oidc_client_secret_from_config(secret) when is_binary(secret) do
|
defp oidc_client_secret_from_config(secret) when is_binary(secret) do
|
||||||
s = String.trim(secret)
|
s = String.trim(secret)
|
||||||
if s != "", do: s, else: env_or_setting("OIDC_CLIENT_SECRET", :oidc_client_secret)
|
if s != "", do: s, else: oidc_client_secret_from_env_or_settings()
|
||||||
end
|
end
|
||||||
|
|
||||||
defp oidc_client_secret_from_config(_),
|
defp oidc_client_secret_from_config(_),
|
||||||
do: env_or_setting("OIDC_CLIENT_SECRET", :oidc_client_secret)
|
do: oidc_client_secret_from_env_or_settings()
|
||||||
|
|
||||||
|
defp oidc_client_secret_from_env_or_settings do
|
||||||
|
case System.get_env("OIDC_CLIENT_SECRET") do
|
||||||
|
nil -> get_oidc_client_secret_from_settings()
|
||||||
|
value -> trim_nil(value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns the OIDC admin group name (for role sync). ENV first, then Settings.
|
Returns the OIDC admin group name (for role sync). ENV first, then Settings.
|
||||||
|
|
@ -449,4 +464,206 @@ defmodule Mv.Config do
|
||||||
def oidc_admin_group_name_env_set?, do: env_set?("OIDC_ADMIN_GROUP_NAME")
|
def oidc_admin_group_name_env_set?, do: env_set?("OIDC_ADMIN_GROUP_NAME")
|
||||||
def oidc_groups_claim_env_set?, do: env_set?("OIDC_GROUPS_CLAIM")
|
def oidc_groups_claim_env_set?, do: env_set?("OIDC_GROUPS_CLAIM")
|
||||||
def oidc_only_env_set?, do: env_set?("OIDC_ONLY")
|
def oidc_only_env_set?, do: env_set?("OIDC_ONLY")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SMTP configuration – ENV overrides Settings; see docs/smtp-configuration-concept.md
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns SMTP host. ENV `SMTP_HOST` overrides Settings.
|
||||||
|
"""
|
||||||
|
@spec smtp_host() :: String.t() | nil
|
||||||
|
def smtp_host do
|
||||||
|
smtp_env_or_setting("SMTP_HOST", :smtp_host)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns SMTP port as integer. ENV `SMTP_PORT` (parsed) overrides Settings.
|
||||||
|
Returns nil when neither ENV nor Settings provide a valid port.
|
||||||
|
"""
|
||||||
|
@spec smtp_port() :: non_neg_integer() | nil
|
||||||
|
def smtp_port do
|
||||||
|
case System.get_env("SMTP_PORT") do
|
||||||
|
nil ->
|
||||||
|
get_from_settings_integer(:smtp_port)
|
||||||
|
|
||||||
|
value when is_binary(value) ->
|
||||||
|
case Integer.parse(String.trim(value)) do
|
||||||
|
{port, _} when port > 0 -> port
|
||||||
|
_ -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns SMTP username. ENV `SMTP_USERNAME` overrides Settings.
|
||||||
|
"""
|
||||||
|
@spec smtp_username() :: String.t() | nil
|
||||||
|
def smtp_username do
|
||||||
|
smtp_env_or_setting("SMTP_USERNAME", :smtp_username)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns SMTP password.
|
||||||
|
|
||||||
|
Priority: `SMTP_PASSWORD` ENV > `SMTP_PASSWORD_FILE` (file contents) > Settings.
|
||||||
|
Strips trailing whitespace/newlines from file contents.
|
||||||
|
"""
|
||||||
|
@spec smtp_password() :: String.t() | nil
|
||||||
|
def smtp_password do
|
||||||
|
case System.get_env("SMTP_PASSWORD") do
|
||||||
|
nil -> smtp_password_from_file_or_settings()
|
||||||
|
value -> trim_nil(value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp smtp_password_from_file_or_settings do
|
||||||
|
case System.get_env("SMTP_PASSWORD_FILE") do
|
||||||
|
nil -> get_smtp_password_from_settings()
|
||||||
|
path -> read_smtp_password_file(path)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp read_smtp_password_file(path) do
|
||||||
|
case File.read(String.trim(path)) do
|
||||||
|
{:ok, content} -> trim_nil(content)
|
||||||
|
{:error, _} -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns SMTP TLS/SSL mode string (e.g. 'tls', 'ssl', 'none').
|
||||||
|
ENV `SMTP_SSL` overrides Settings.
|
||||||
|
"""
|
||||||
|
@spec smtp_ssl() :: String.t() | nil
|
||||||
|
def smtp_ssl do
|
||||||
|
smtp_env_or_setting("SMTP_SSL", :smtp_ssl)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns true when SMTP is configured (host present from ENV or Settings).
|
||||||
|
"""
|
||||||
|
@spec smtp_configured?() :: boolean()
|
||||||
|
def smtp_configured? do
|
||||||
|
present?(smtp_host())
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns true when any SMTP ENV variable is set (used in Settings UI for hints).
|
||||||
|
"""
|
||||||
|
@spec smtp_env_configured?() :: boolean()
|
||||||
|
def smtp_env_configured? do
|
||||||
|
smtp_host_env_set?() or smtp_port_env_set?() or smtp_username_env_set?() or
|
||||||
|
smtp_password_env_set?() or smtp_ssl_env_set?()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc "Returns true if SMTP_HOST ENV is set."
|
||||||
|
@spec smtp_host_env_set?() :: boolean()
|
||||||
|
def smtp_host_env_set?, do: env_set?("SMTP_HOST")
|
||||||
|
|
||||||
|
@doc "Returns true if SMTP_PORT ENV is set."
|
||||||
|
@spec smtp_port_env_set?() :: boolean()
|
||||||
|
def smtp_port_env_set?, do: env_set?("SMTP_PORT")
|
||||||
|
|
||||||
|
@doc "Returns true if SMTP_USERNAME ENV is set."
|
||||||
|
@spec smtp_username_env_set?() :: boolean()
|
||||||
|
def smtp_username_env_set?, do: env_set?("SMTP_USERNAME")
|
||||||
|
|
||||||
|
@doc "Returns true if SMTP_PASSWORD or SMTP_PASSWORD_FILE ENV is set."
|
||||||
|
@spec smtp_password_env_set?() :: boolean()
|
||||||
|
def smtp_password_env_set?, do: env_set?("SMTP_PASSWORD") or env_set?("SMTP_PASSWORD_FILE")
|
||||||
|
|
||||||
|
@doc "Returns true if SMTP_SSL ENV is set."
|
||||||
|
@spec smtp_ssl_env_set?() :: boolean()
|
||||||
|
def smtp_ssl_env_set?, do: env_set?("SMTP_SSL")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Transactional email sender identity (mail_from)
|
||||||
|
# ENV variables MAIL_FROM_NAME / MAIL_FROM_EMAIL take priority; fallback to
|
||||||
|
# Settings smtp_from_name / smtp_from_email; final fallback: hardcoded defaults.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the display name for the transactional email sender.
|
||||||
|
|
||||||
|
Priority: `MAIL_FROM_NAME` ENV > Settings `smtp_from_name` > `"Mila"`.
|
||||||
|
"""
|
||||||
|
@spec mail_from_name() :: String.t()
|
||||||
|
def mail_from_name do
|
||||||
|
case System.get_env("MAIL_FROM_NAME") do
|
||||||
|
nil -> get_from_settings(:smtp_from_name) || "Mila"
|
||||||
|
value -> trim_nil(value) || "Mila"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the email address for the transactional email sender.
|
||||||
|
|
||||||
|
Priority: `MAIL_FROM_EMAIL` ENV > Settings `smtp_from_email` > `nil`.
|
||||||
|
Returns `nil` when not configured (caller should fall back to a safe default).
|
||||||
|
"""
|
||||||
|
@spec mail_from_email() :: String.t() | nil
|
||||||
|
def mail_from_email do
|
||||||
|
case System.get_env("MAIL_FROM_EMAIL") do
|
||||||
|
nil -> get_from_settings(:smtp_from_email)
|
||||||
|
value -> trim_nil(value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc "Returns true if MAIL_FROM_NAME ENV is set."
|
||||||
|
@spec mail_from_name_env_set?() :: boolean()
|
||||||
|
def mail_from_name_env_set?, do: env_set?("MAIL_FROM_NAME")
|
||||||
|
|
||||||
|
@doc "Returns true if MAIL_FROM_EMAIL ENV is set."
|
||||||
|
@spec mail_from_email_env_set?() :: boolean()
|
||||||
|
def mail_from_email_env_set?, do: env_set?("MAIL_FROM_EMAIL")
|
||||||
|
|
||||||
|
# Reads a plain string SMTP setting: ENV first, then Settings.
|
||||||
|
defp smtp_env_or_setting(env_key, setting_key) do
|
||||||
|
case System.get_env(env_key) do
|
||||||
|
nil -> get_from_settings(setting_key)
|
||||||
|
value -> trim_nil(value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Reads an integer setting attribute from Settings.
|
||||||
|
defp get_from_settings_integer(key) do
|
||||||
|
case Mv.Membership.get_settings() do
|
||||||
|
{:ok, settings} ->
|
||||||
|
case Map.get(settings, key) do
|
||||||
|
v when is_integer(v) and v > 0 -> v
|
||||||
|
_ -> nil
|
||||||
|
end
|
||||||
|
|
||||||
|
{:error, _} ->
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Reads the SMTP password directly from the DB via an explicit select,
|
||||||
|
# bypassing the standard read action which excludes smtp_password for security.
|
||||||
|
defp get_smtp_password_from_settings do
|
||||||
|
query = Ash.Query.select(Mv.Membership.Setting, [:id, :smtp_password])
|
||||||
|
|
||||||
|
case Ash.read_one(query, authorize?: false, domain: Mv.Membership) do
|
||||||
|
{:ok, settings} when not is_nil(settings) ->
|
||||||
|
settings |> Map.get(:smtp_password) |> trim_nil()
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Reads the OIDC client secret via explicit select (excluded from default read, same as smtp_password).
|
||||||
|
defp get_oidc_client_secret_from_settings do
|
||||||
|
query = Ash.Query.select(Mv.Membership.Setting, [:id, :oidc_client_secret])
|
||||||
|
|
||||||
|
case Ash.read_one(query, authorize?: false, domain: Mv.Membership) do
|
||||||
|
{:ok, settings} when not is_nil(settings) ->
|
||||||
|
settings |> Map.get(:oidc_client_secret) |> trim_nil()
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
182
lib/mv/mailer.ex
182
lib/mv/mailer.ex
|
|
@ -4,16 +4,188 @@ defmodule Mv.Mailer do
|
||||||
|
|
||||||
Use `mail_from/0` for the configured sender address (join confirmation,
|
Use `mail_from/0` for the configured sender address (join confirmation,
|
||||||
user confirmation, password reset).
|
user confirmation, password reset).
|
||||||
|
|
||||||
|
## Sender identity
|
||||||
|
|
||||||
|
The "from" address is determined by priority:
|
||||||
|
1. `MAIL_FROM_EMAIL` / `MAIL_FROM_NAME` environment variables
|
||||||
|
2. Settings database (`smtp_from_email`, `smtp_from_name`)
|
||||||
|
3. Hardcoded default (`"Mila"`, `"noreply@example.com"`)
|
||||||
|
|
||||||
|
**Important:** On most SMTP servers the sender email must be owned by the
|
||||||
|
authenticated SMTP user. Set `smtp_from_email` to the same address as
|
||||||
|
`smtp_username` (or an alias allowed by the server).
|
||||||
|
|
||||||
|
## SMTP adapter configuration
|
||||||
|
|
||||||
|
The SMTP adapter can be configured via:
|
||||||
|
- **Environment variables** at boot (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`,
|
||||||
|
`SMTP_PASSWORD`, `SMTP_PASSWORD_FILE`, `SMTP_SSL`) — configured in `runtime.exs`.
|
||||||
|
- **Admin Settings** (database) — read at send time via `Mv.Config.smtp_*()` helpers.
|
||||||
|
Settings-based config is passed per-send via `smtp_config/0`.
|
||||||
|
|
||||||
|
ENV takes priority over Settings (same pattern as OIDC and Vereinfacht).
|
||||||
"""
|
"""
|
||||||
use Swoosh.Mailer, otp_app: :mv
|
use Swoosh.Mailer, otp_app: :mv
|
||||||
|
|
||||||
@doc """
|
import Swoosh.Email
|
||||||
Returns the configured "from" address for transactional emails.
|
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
|
||||||
|
|
||||||
Configure in config.exs or runtime.exs as `config :mv, :mail_from, {name, email}`.
|
alias Mv.Smtp.ConfigBuilder
|
||||||
Default: `{"Mila", "noreply@example.com"}`.
|
require Logger
|
||||||
|
|
||||||
|
# Simple format check for test-email recipient only (e.g. allows a@b.c). Not for strict RFC validation.
|
||||||
|
@email_regex ~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the configured "from" address for transactional emails as `{name, email}`.
|
||||||
|
|
||||||
|
Priority: ENV `MAIL_FROM_NAME`/`MAIL_FROM_EMAIL` > Settings `smtp_from_name`/`smtp_from_email` > defaults.
|
||||||
"""
|
"""
|
||||||
|
@spec mail_from() :: {String.t(), String.t()}
|
||||||
def mail_from do
|
def mail_from do
|
||||||
Application.get_env(:mv, :mail_from, {"Mila", "noreply@example.com"})
|
{Mv.Config.mail_from_name(), Mv.Config.mail_from_email() || "noreply@example.com"}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Sends a test email to the given address. Used from Global Settings SMTP section.
|
||||||
|
|
||||||
|
Returns `{:ok, email}` on success, `{:error, reason}` on failure.
|
||||||
|
The `reason` is a classified atom for known error categories, or `{:smtp_error, message}`
|
||||||
|
for SMTP-level errors with a human-readable message, or the raw term for unknown errors.
|
||||||
|
"""
|
||||||
|
@spec send_test_email(String.t()) ::
|
||||||
|
{:ok, Swoosh.Email.t()} | {:error, atom() | {:smtp_error, String.t()} | term()}
|
||||||
|
def send_test_email(to_email) when is_binary(to_email) do
|
||||||
|
if valid_email?(to_email) do
|
||||||
|
subject = gettext("Mila – Test email")
|
||||||
|
|
||||||
|
body =
|
||||||
|
gettext(
|
||||||
|
"This is a test email sent from Mila. If you received this, your SMTP configuration is working correctly."
|
||||||
|
)
|
||||||
|
|
||||||
|
email =
|
||||||
|
new()
|
||||||
|
|> from(mail_from())
|
||||||
|
|> to(to_email)
|
||||||
|
|> subject(subject)
|
||||||
|
|> text_body(body)
|
||||||
|
|> html_body("<p>#{body}</p>")
|
||||||
|
|
||||||
|
case deliver(email, smtp_config()) do
|
||||||
|
{:ok, _} = ok ->
|
||||||
|
ok
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
classified = classify_smtp_error(reason)
|
||||||
|
Logger.warning("SMTP test email failed: #{inspect(reason)}")
|
||||||
|
{:error, classified}
|
||||||
|
end
|
||||||
|
else
|
||||||
|
{:error, :invalid_email_address}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def send_test_email(_), do: {:error, :invalid_email_address}
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Builds the per-send SMTP config from `Mv.Config` when SMTP is configured via
|
||||||
|
Settings only (not boot-time ENV). Returns an empty list when the mailer is
|
||||||
|
already configured at boot (ENV-based), so Swoosh uses the Application config.
|
||||||
|
|
||||||
|
The return value must be a flat keyword list (adapter, relay, port, ...).
|
||||||
|
Swoosh merges it with Application config; top-level keys override the mailer's
|
||||||
|
default adapter (e.g. Local in dev), so this delivery uses SMTP.
|
||||||
|
"""
|
||||||
|
@spec smtp_config() :: keyword()
|
||||||
|
def smtp_config do
|
||||||
|
if Mv.Config.smtp_configured?() and not boot_smtp_configured?() do
|
||||||
|
verify_mode =
|
||||||
|
if Application.get_env(:mv, :smtp_verify_peer, false),
|
||||||
|
do: :verify_peer,
|
||||||
|
else: :verify_none
|
||||||
|
|
||||||
|
ConfigBuilder.build_opts(
|
||||||
|
host: Mv.Config.smtp_host(),
|
||||||
|
port: Mv.Config.smtp_port() || 587,
|
||||||
|
username: Mv.Config.smtp_username(),
|
||||||
|
password: Mv.Config.smtp_password(),
|
||||||
|
ssl_mode: Mv.Config.smtp_ssl() || "tls",
|
||||||
|
verify_mode: verify_mode
|
||||||
|
)
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SMTP error classification
|
||||||
|
# Maps raw gen_smtp error terms to human-readable atoms / structs.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@spec classify_smtp_error(term()) ::
|
||||||
|
:sender_rejected
|
||||||
|
| :auth_failed
|
||||||
|
| :recipient_rejected
|
||||||
|
| :tls_failed
|
||||||
|
| :connection_failed
|
||||||
|
| {:smtp_error, String.t()}
|
||||||
|
| term()
|
||||||
|
def classify_smtp_error({:retries_exceeded, {:temporary_failure, _host, :tls_failed}}),
|
||||||
|
do: :tls_failed
|
||||||
|
|
||||||
|
def classify_smtp_error({:retries_exceeded, {:network_failure, _host, _}}),
|
||||||
|
do: :connection_failed
|
||||||
|
|
||||||
|
def classify_smtp_error({:send, {:permanent_failure, _host, msg}}) do
|
||||||
|
str = if is_list(msg), do: List.to_string(msg), else: to_string(msg)
|
||||||
|
classify_permanent_failure_message(str)
|
||||||
|
end
|
||||||
|
|
||||||
|
def classify_smtp_error(reason), do: reason
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Private helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
defp classify_permanent_failure_message(str) do
|
||||||
|
cond do
|
||||||
|
smtp_auth_failure?(str) -> :auth_failed
|
||||||
|
smtp_sender_rejected?(str) -> :sender_rejected
|
||||||
|
smtp_recipient_rejected?(str) -> :recipient_rejected
|
||||||
|
true -> {:smtp_error, String.trim(str)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp smtp_auth_failure?(str),
|
||||||
|
do:
|
||||||
|
String.contains?(str, "535") or String.contains?(str, "authentication") or
|
||||||
|
String.contains?(str, "Authentication")
|
||||||
|
|
||||||
|
defp smtp_sender_rejected?(str),
|
||||||
|
do:
|
||||||
|
String.contains?(str, "553") or String.contains?(str, "Sender address rejected") or
|
||||||
|
String.contains?(str, "not owned")
|
||||||
|
|
||||||
|
defp smtp_recipient_rejected?(str),
|
||||||
|
do:
|
||||||
|
String.contains?(str, "550") or String.contains?(str, "No such user") or
|
||||||
|
String.contains?(str, "no such user") or String.contains?(str, "User unknown")
|
||||||
|
|
||||||
|
# Returns true when the SMTP adapter has been configured at boot time via ENV
|
||||||
|
# (i.e. the Application config is already set to the SMTP adapter).
|
||||||
|
defp boot_smtp_configured? do
|
||||||
|
case Application.get_env(:mv, __MODULE__) do
|
||||||
|
config when is_list(config) -> Keyword.get(config, :adapter) == Swoosh.Adapters.SMTP
|
||||||
|
_ -> false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp valid_email?(email) when is_binary(email) do
|
||||||
|
Regex.match?(@email_regex, String.trim(email))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp valid_email?(_), do: false
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ defmodule Mv.Release do
|
||||||
## Tasks
|
## Tasks
|
||||||
|
|
||||||
- `migrate/0` - Runs all pending Ecto migrations.
|
- `migrate/0` - Runs all pending Ecto migrations.
|
||||||
- `run_seeds/0` - Runs bootstrap seeds (fee types, custom fields, roles, settings).
|
- `bootstrap_seeds_applied?/0` - Returns whether bootstrap was already applied (admin user exists). Used to skip re-running seeds.
|
||||||
In production, set `RUN_DEV_SEEDS=true` to also run dev seeds (members, groups, sample data).
|
- `run_seeds/0` - If bootstrap already applied, skips; otherwise runs bootstrap seeds (fee types, custom fields, roles, settings). Set `FORCE_SEEDS=true` to re-run seeds even when already applied. In production, set `RUN_DEV_SEEDS=true` to also run dev seeds (members, groups, sample data).
|
||||||
- `seed_admin/0` - Ensures an admin user exists from ENV (ADMIN_EMAIL, ADMIN_PASSWORD
|
- `seed_admin/0` - Ensures an admin user exists from ENV (ADMIN_EMAIL, ADMIN_PASSWORD
|
||||||
or ADMIN_PASSWORD_FILE). Idempotent; can be run on every deployment or via shell
|
or ADMIN_PASSWORD_FILE). Idempotent; can be run on every deployment or via shell
|
||||||
to update the admin password without redeploying.
|
to update the admin password without redeploying.
|
||||||
|
|
@ -19,6 +19,7 @@ defmodule Mv.Release do
|
||||||
alias Mv.Authorization.Role
|
alias Mv.Authorization.Role
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
require Logger
|
||||||
|
|
||||||
def migrate do
|
def migrate do
|
||||||
load_app()
|
load_app()
|
||||||
|
|
@ -28,13 +29,37 @@ defmodule Mv.Release do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns whether bootstrap seeds have already been applied (admin user exists).
|
||||||
|
|
||||||
|
We check for the admin user (from ADMIN_EMAIL or default), not the Admin role,
|
||||||
|
because migrations may create the Admin role for the system actor. Only seeds
|
||||||
|
create the admin (login) user. Used to skip re-running seeds on subsequent starts.
|
||||||
|
Call only when the application is already started.
|
||||||
|
"""
|
||||||
|
def bootstrap_seeds_applied? do
|
||||||
|
admin_email = get_env("ADMIN_EMAIL", "admin@localhost")
|
||||||
|
|
||||||
|
case User
|
||||||
|
|> Ash.Query.filter(email == ^admin_email)
|
||||||
|
|> Ash.read_one(authorize?: false, domain: Mv.Accounts) do
|
||||||
|
{:ok, %User{}} -> true
|
||||||
|
_ -> false
|
||||||
|
end
|
||||||
|
rescue
|
||||||
|
e ->
|
||||||
|
Logger.warning("Could not check seed status (#{inspect(e)}), assuming not applied.")
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Runs seed scripts so the database has required bootstrap data (and optionally dev data).
|
Runs seed scripts so the database has required bootstrap data (and optionally dev data).
|
||||||
|
|
||||||
- Always runs bootstrap seeds (fee types, custom fields, roles, system user, settings).
|
- Skips if bootstrap was already applied (admin user exists); set `FORCE_SEEDS=true` to override and re-run.
|
||||||
- If `RUN_DEV_SEEDS` env is set to `"true"`, also runs dev seeds (members, groups, sample data).
|
- If `RUN_DEV_SEEDS` env is set to `"true"`, also runs dev seeds (members, groups, sample data)
|
||||||
|
when bootstrap is run.
|
||||||
|
|
||||||
Uses paths from the application's priv dir so it works in releases (no Mix). Idempotent.
|
Uses paths from the application's priv dir so it works in releases (no Mix).
|
||||||
"""
|
"""
|
||||||
def run_seeds do
|
def run_seeds do
|
||||||
case Application.ensure_all_started(@app) do
|
case Application.ensure_all_started(@app) do
|
||||||
|
|
@ -42,6 +67,9 @@ defmodule Mv.Release do
|
||||||
{:error, {app, reason}} -> raise "Failed to start #{inspect(app)}: #{inspect(reason)}"
|
{:error, {app, reason}} -> raise "Failed to start #{inspect(app)}: #{inspect(reason)}"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if bootstrap_seeds_applied?() and System.get_env("FORCE_SEEDS") != "true" do
|
||||||
|
IO.puts("Seeds already applied. Skipping. (Set FORCE_SEEDS=true to override)")
|
||||||
|
else
|
||||||
priv = :code.priv_dir(@app)
|
priv = :code.priv_dir(@app)
|
||||||
bootstrap_path = Path.join(priv, "repo/seeds_bootstrap.exs")
|
bootstrap_path = Path.join(priv, "repo/seeds_bootstrap.exs")
|
||||||
dev_path = Path.join(priv, "repo/seeds_dev.exs")
|
dev_path = Path.join(priv, "repo/seeds_dev.exs")
|
||||||
|
|
@ -61,6 +89,7 @@ defmodule Mv.Release do
|
||||||
Code.compiler_options(prev)
|
Code.compiler_options(prev)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def rollback(repo, version) do
|
def rollback(repo, version) do
|
||||||
load_app()
|
load_app()
|
||||||
|
|
|
||||||
58
lib/mv/smtp/config_builder.ex
Normal file
58
lib/mv/smtp/config_builder.ex
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
defmodule Mv.Smtp.ConfigBuilder do
|
||||||
|
@moduledoc """
|
||||||
|
Builds Swoosh/gen_smtp SMTP adapter options from connection parameters.
|
||||||
|
|
||||||
|
Single source of truth for TLS/sockopts logic (port 587 vs 465):
|
||||||
|
- Port 587 (STARTTLS): `gen_tcp` is used first; `sockopts` must NOT contain `:verify`.
|
||||||
|
- Port 465 (implicit SSL): initial connection is `ssl:connect`; `sockopts` must contain `:verify`.
|
||||||
|
|
||||||
|
Used by `config/runtime.exs` (boot-time ENV) and `Mv.Mailer.smtp_config/0` (Settings-only).
|
||||||
|
"""
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Builds the keyword list of Swoosh SMTP adapter options.
|
||||||
|
|
||||||
|
Options (keyword list):
|
||||||
|
- `:host` (required) — relay hostname
|
||||||
|
- `:port` (required) — port number (e.g. 587 or 465)
|
||||||
|
- `:ssl_mode` (required) — `"tls"` or `"ssl"`
|
||||||
|
- `:verify_mode` (required) — `:verify_peer` or `:verify_none`
|
||||||
|
- `:username` (optional)
|
||||||
|
- `:password` (optional)
|
||||||
|
|
||||||
|
Nil values are stripped from the result.
|
||||||
|
"""
|
||||||
|
@spec build_opts(keyword()) :: keyword()
|
||||||
|
def build_opts(opts) do
|
||||||
|
host = Keyword.fetch!(opts, :host)
|
||||||
|
port = Keyword.fetch!(opts, :port)
|
||||||
|
username = Keyword.get(opts, :username)
|
||||||
|
password = Keyword.get(opts, :password)
|
||||||
|
ssl_mode = Keyword.fetch!(opts, :ssl_mode)
|
||||||
|
verify_mode = Keyword.fetch!(opts, :verify_mode)
|
||||||
|
|
||||||
|
base_opts = [
|
||||||
|
adapter: Swoosh.Adapters.SMTP,
|
||||||
|
relay: host,
|
||||||
|
port: port,
|
||||||
|
username: username,
|
||||||
|
password: password,
|
||||||
|
ssl: ssl_mode == "ssl",
|
||||||
|
tls: if(ssl_mode == "tls", do: :always, else: :never),
|
||||||
|
auth: :always,
|
||||||
|
# tls_options: used for STARTTLS (587). For 465, gen_smtp uses sockopts for initial ssl:connect.
|
||||||
|
tls_options: [verify: verify_mode]
|
||||||
|
]
|
||||||
|
|
||||||
|
# Port 465: initial connection is ssl:connect; pass verify in sockopts.
|
||||||
|
# Port 587: initial connection is gen_tcp; sockopts must NOT contain verify (gen_tcp rejects it).
|
||||||
|
opts =
|
||||||
|
if ssl_mode == "ssl" do
|
||||||
|
Keyword.put(base_opts, :sockopts, verify: verify_mode)
|
||||||
|
else
|
||||||
|
base_opts
|
||||||
|
end
|
||||||
|
|
||||||
|
Enum.reject(opts, fn {_k, v} -> is_nil(v) end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -3,52 +3,70 @@ defmodule MvWeb.AuthOverrides do
|
||||||
UI customizations for AshAuthentication Phoenix components.
|
UI customizations for AshAuthentication Phoenix components.
|
||||||
|
|
||||||
## Overrides
|
## Overrides
|
||||||
- `SignIn` - Restricts form width to prevent full-width display
|
- `SignIn` - Restricts form width and hides the library banner (title is rendered in SignInLive)
|
||||||
- `Banner` - Replaces default logo with "Mitgliederverwaltung" text
|
- `Banner` - Replaces default logo with text for reset/confirm pages
|
||||||
- `HorizontalRule` - Translates "or" text to German
|
- `Flash` - Hides library flash (we use flash_group in root layout)
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
For complete reference on available overrides, see:
|
For complete reference on available overrides, see:
|
||||||
https://hexdocs.pm/ash_authentication_phoenix/ui-overrides.html
|
https://hexdocs.pm/ash_authentication_phoenix/ui-overrides.html
|
||||||
"""
|
"""
|
||||||
use AshAuthentication.Phoenix.Overrides
|
use AshAuthentication.Phoenix.Overrides
|
||||||
use Gettext, backend: MvWeb.Gettext
|
|
||||||
|
|
||||||
# configure your UI overrides here
|
# Avoid full-width for the Sign In Form.
|
||||||
|
# Banner is hidden because SignInLive renders its own locale-aware title.
|
||||||
# First argument to `override` is the component name you are overriding.
|
|
||||||
# The body contains any number of configurations you wish to override
|
|
||||||
# Below are some examples
|
|
||||||
|
|
||||||
# For a complete reference, see https://hexdocs.pm/ash_authentication_phoenix/ui-overrides.html
|
|
||||||
|
|
||||||
# override AshAuthentication.Phoenix.Components.Banner do
|
|
||||||
# set :image_url, "https://media.giphy.com/media/g7GKcSzwQfugw/giphy.gif"
|
|
||||||
# set :text_class, "bg-red-500"
|
|
||||||
# end
|
|
||||||
|
|
||||||
# Avoid full-width for the Sign In Form
|
|
||||||
override AshAuthentication.Phoenix.Components.SignIn do
|
override AshAuthentication.Phoenix.Components.SignIn do
|
||||||
set :root_class, "md:min-w-md"
|
set :root_class, "md:min-w-md"
|
||||||
|
set :show_banner, false
|
||||||
end
|
end
|
||||||
|
|
||||||
# Replace banner logo with text (no image in light or dark so link has discernible text)
|
# Replace banner logo with text for reset/confirm pages (no image so link has discernible text).
|
||||||
override AshAuthentication.Phoenix.Components.Banner do
|
override AshAuthentication.Phoenix.Components.Banner do
|
||||||
set :text, "Mitgliederverwaltung"
|
set :text, "Mitgliederverwaltung"
|
||||||
set :image_url, nil
|
set :image_url, nil
|
||||||
set :dark_image_url, nil
|
set :dark_image_url, nil
|
||||||
end
|
end
|
||||||
|
|
||||||
# Translate the "or" in the horizontal rule (between password form and SSO).
|
# Hide AshAuthentication's Flash component since we use flash_group in root layout.
|
||||||
# Uses auth domain so it respects the current locale (e.g. "oder" in German).
|
# This prevents duplicate flash messages.
|
||||||
override AshAuthentication.Phoenix.Components.HorizontalRule do
|
|
||||||
set :text, dgettext("auth", "or")
|
|
||||||
end
|
|
||||||
|
|
||||||
# Hide AshAuthentication's Flash component since we use flash_group in root layout
|
|
||||||
# This prevents duplicate flash messages
|
|
||||||
override AshAuthentication.Phoenix.Components.Flash do
|
override AshAuthentication.Phoenix.Components.Flash do
|
||||||
set :message_class_info, "hidden"
|
set :message_class_info, "hidden"
|
||||||
set :message_class_error, "hidden"
|
set :message_class_error, "hidden"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defmodule MvWeb.AuthOverridesRegistrationDisabled do
|
||||||
|
@moduledoc """
|
||||||
|
When direct registration is disabled in global settings, this override is
|
||||||
|
prepended in SignInLive so the Password component hides the "Need an account?"
|
||||||
|
toggle (register_toggle_text: nil disables the register link per library docs).
|
||||||
|
"""
|
||||||
|
use AshAuthentication.Phoenix.Overrides
|
||||||
|
|
||||||
|
override AshAuthentication.Phoenix.Components.Password do
|
||||||
|
set :register_toggle_text, nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule MvWeb.AuthOverridesDE do
|
||||||
|
@moduledoc """
|
||||||
|
German locale-specific overrides for AshAuthentication Phoenix components.
|
||||||
|
|
||||||
|
Prepended to the overrides list in SignInLive when the locale is "de".
|
||||||
|
Provides runtime-static German text for components that do not use
|
||||||
|
the `_gettext` mechanism (e.g. HorizontalRule renders its text directly),
|
||||||
|
and for submit buttons whose disable_text bypasses the POT extraction pipeline.
|
||||||
|
"""
|
||||||
|
use AshAuthentication.Phoenix.Overrides
|
||||||
|
|
||||||
|
# HorizontalRule renders text without `_gettext`, so we need a static German string.
|
||||||
|
override AshAuthentication.Phoenix.Components.HorizontalRule do
|
||||||
|
set :text, "oder"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Registering ... disable-text is passed through _gettext but "Registering ..."
|
||||||
|
# has no dgettext source reference, so we supply the German string directly.
|
||||||
|
override AshAuthentication.Phoenix.Components.Password.RegisterForm do
|
||||||
|
set :disable_button_text, "Registrieren..."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,11 @@ defmodule MvWeb.CoreComponents do
|
||||||
values: [:info, :error, :success, :warning],
|
values: [:info, :error, :success, :warning],
|
||||||
doc: "used for styling and flash lookup"
|
doc: "used for styling and flash lookup"
|
||||||
|
|
||||||
|
attr :auto_clear_ms, :integer,
|
||||||
|
default: nil,
|
||||||
|
doc:
|
||||||
|
"when set, flash is auto-dismissed after this many milliseconds (e.g. 5000 for success toasts)"
|
||||||
|
|
||||||
attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
|
attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
|
||||||
|
|
||||||
slot :inner_block, doc: "the optional inner block that renders the flash message"
|
slot :inner_block, doc: "the optional inner block that renders the flash message"
|
||||||
|
|
@ -74,6 +79,9 @@ defmodule MvWeb.CoreComponents do
|
||||||
<div
|
<div
|
||||||
:if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
|
:if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
|
||||||
id={@id}
|
id={@id}
|
||||||
|
phx-hook={@auto_clear_ms && "FlashAutoDismiss"}
|
||||||
|
data-auto-clear-ms={@auto_clear_ms}
|
||||||
|
data-clear-flash-key={@auto_clear_ms && @kind}
|
||||||
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
|
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
|
||||||
role="alert"
|
role="alert"
|
||||||
class="pointer-events-auto"
|
class="pointer-events-auto"
|
||||||
|
|
@ -1295,6 +1303,41 @@ defmodule MvWeb.CoreComponents do
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Renders a theme toggle using DaisyUI swap (sun/moon with rotate effect).
|
||||||
|
|
||||||
|
Wired to the theme script in root layout: checkbox uses `data-theme-toggle`,
|
||||||
|
root script syncs checked state (checked = dark) and listens for `phx:set-theme`.
|
||||||
|
Use in public header or sidebar. Optional `class` is applied to the wrapper.
|
||||||
|
"""
|
||||||
|
attr :class, :string, default: nil, doc: "Optional extra classes for the swap wrapper"
|
||||||
|
|
||||||
|
def theme_swap(assigns) do
|
||||||
|
assigns = assign(assigns, :wrapper_class, assigns[:class])
|
||||||
|
|
||||||
|
~H"""
|
||||||
|
<div class={[@wrapper_class]}>
|
||||||
|
<label
|
||||||
|
class="swap swap-rotate cursor-pointer focus-within:outline-none focus-within:focus-visible:ring-2 focus-within:focus-visible:ring-primary focus-within:focus-visible:ring-offset-2 rounded"
|
||||||
|
aria-label={gettext("Toggle dark mode")}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
data-theme-toggle
|
||||||
|
aria-label={gettext("Toggle dark mode")}
|
||||||
|
onchange="window.dispatchEvent(new CustomEvent('phx:set-theme',{detail:{theme:this.checked?'dark':'light'}}))"
|
||||||
|
/>
|
||||||
|
<span class="swap-on size-6 flex items-center justify-center" aria-hidden="true">
|
||||||
|
<.icon name="hero-moon" class="size-5" />
|
||||||
|
</span>
|
||||||
|
<span class="swap-off size-6 flex items-center justify-center" aria-hidden="true">
|
||||||
|
<.icon name="hero-sun" class="size-5" />
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Renders a [Heroicon](https://heroicons.com).
|
Renders a [Heroicon](https://heroicons.com).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,98 @@ defmodule MvWeb.Layouts do
|
||||||
|
|
||||||
embed_templates "layouts/*"
|
embed_templates "layouts/*"
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Builds the full browser tab title: "Mila", "Mila · Page", or "Mila · Page · Club".
|
||||||
|
Order is always: Mila · page title · club name.
|
||||||
|
Uses assigns[:club_name] and the short page label from assigns[:content_title] or
|
||||||
|
assigns[:page_title]. LiveViews should set content_title (same gettext as sidebar)
|
||||||
|
and then assign page_title to the result of this function so the client receives
|
||||||
|
the full title.
|
||||||
|
"""
|
||||||
|
def page_title_string(assigns) do
|
||||||
|
club = assigns[:club_name]
|
||||||
|
page = assigns[:content_title] || assigns[:page_title]
|
||||||
|
|
||||||
|
parts =
|
||||||
|
[page, club]
|
||||||
|
|> Enum.filter(&(is_binary(&1) and String.trim(&1) != ""))
|
||||||
|
|
||||||
|
if parts == [] do
|
||||||
|
"Mila"
|
||||||
|
else
|
||||||
|
"Mila · " <> Enum.join(parts, " · ")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Assigns content_title (short label for heading; same gettext as sidebar) and
|
||||||
|
page_title (full browser tab title). Call from LiveView mount after club_name
|
||||||
|
is set (e.g. from on_mount). Returns the socket.
|
||||||
|
"""
|
||||||
|
def assign_page_title(socket, content_title) do
|
||||||
|
socket = assign(socket, :content_title, content_title)
|
||||||
|
assign(socket, :page_title, page_title_string(socket.assigns))
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Renders the public (unauthenticated) page layout: header with logo + "Mitgliederverwaltung" left,
|
||||||
|
club name centered, language selector right; plus main content and flash group. Use for sign-in, join, and join-confirm pages so they
|
||||||
|
share the same chrome without the sidebar or authenticated layout logic.
|
||||||
|
|
||||||
|
Pass optional `:club_name` from the parent (e.g. LiveView mount) to avoid a settings read in the component.
|
||||||
|
"""
|
||||||
|
attr :flash, :map, required: true, doc: "the map of flash messages"
|
||||||
|
|
||||||
|
attr :club_name, :string,
|
||||||
|
default: nil,
|
||||||
|
doc: "optional; if set, avoids get_settings() in the component"
|
||||||
|
|
||||||
|
slot :inner_block, required: true
|
||||||
|
|
||||||
|
def public_page(assigns) do
|
||||||
|
club_name =
|
||||||
|
assigns[:club_name] ||
|
||||||
|
case Mv.Membership.get_settings() do
|
||||||
|
{:ok, s} -> s.club_name || "Mitgliederverwaltung"
|
||||||
|
_ -> "Mitgliederverwaltung"
|
||||||
|
end
|
||||||
|
|
||||||
|
assigns = assign(assigns, :club_name, club_name)
|
||||||
|
|
||||||
|
~H"""
|
||||||
|
<header class="relative flex items-center justify-between p-4 border-b border-base-300 bg-base-100">
|
||||||
|
<div class="flex items-center gap-3 shrink-0 min-w-0 max-w-[45%]">
|
||||||
|
<img src={~p"/images/mila.svg"} alt="Mila Logo" class="size-8 shrink-0" />
|
||||||
|
<span class="text-lg font-bold truncate">Mitgliederverwaltung</span>
|
||||||
|
</div>
|
||||||
|
<span class="absolute left-1/2 -translate-x-1/2 text-lg font-bold text-center max-w-[50%] truncate">
|
||||||
|
{@club_name}
|
||||||
|
</span>
|
||||||
|
<div class="shrink-0 flex items-center gap-2">
|
||||||
|
<form method="post" action={~p"/set_locale"}>
|
||||||
|
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
|
||||||
|
<select
|
||||||
|
name="locale"
|
||||||
|
onchange="this.form.submit()"
|
||||||
|
class="select select-sm focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
||||||
|
aria-label={gettext("Select language")}
|
||||||
|
>
|
||||||
|
<option value="de" selected={Gettext.get_locale(MvWeb.Gettext) == "de"}>Deutsch</option>
|
||||||
|
<option value="en" selected={Gettext.get_locale(MvWeb.Gettext) == "en"}>English</option>
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
<.theme_swap />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="px-4 py-8 sm:px-6">
|
||||||
|
<div class="mx-auto max-full space-y-4">
|
||||||
|
{render_slot(@inner_block)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<.flash_group flash={@flash} />
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Renders the app layout. Can be used with or without a current_user.
|
Renders the app layout. Can be used with or without a current_user.
|
||||||
When current_user is present, it will show the navigation bar.
|
When current_user is present, it will show the navigation bar.
|
||||||
|
|
@ -43,11 +135,11 @@ defmodule MvWeb.Layouts do
|
||||||
slot :inner_block, required: true
|
slot :inner_block, required: true
|
||||||
|
|
||||||
def app(assigns) do
|
def app(assigns) do
|
||||||
club_name = get_club_name()
|
# Single get_settings() for layout; derive club_name and join_form_enabled to avoid duplicate query.
|
||||||
join_form_enabled = Mv.Membership.join_form_enabled?()
|
%{club_name: club_name, join_form_enabled: join_form_enabled} = get_layout_settings()
|
||||||
|
|
||||||
# TODO: get_join_form_enabled and unprocessed count run on every page load; consider
|
# TODO: unprocessed count runs on every page load when join form enabled; consider
|
||||||
# loading count only on navigation or caching briefly if performance becomes an issue.
|
# loading only on navigation or caching briefly if performance becomes an issue.
|
||||||
unprocessed_join_requests_count =
|
unprocessed_join_requests_count =
|
||||||
get_unprocessed_join_requests_count(assigns.current_user, join_form_enabled)
|
get_unprocessed_join_requests_count(assigns.current_user, join_form_enabled)
|
||||||
|
|
||||||
|
|
@ -99,13 +191,17 @@ defmodule MvWeb.Layouts do
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<!-- Unauthenticated: simple header (logo, club name, language selector; same classes as sidebar header) -->
|
<!-- Unauthenticated: Option 3 header (logo + app name left, club name center, language selector right) -->
|
||||||
<header class="flex items-center gap-3 p-4 border-b border-base-300 bg-base-100">
|
<header class="relative flex items-center justify-between p-4 border-b border-base-300 bg-base-100">
|
||||||
|
<div class="flex items-center gap-3 shrink-0 min-w-0 max-w-[45%]">
|
||||||
<img src={~p"/images/mila.svg"} alt="Mila Logo" class="size-8 shrink-0" />
|
<img src={~p"/images/mila.svg"} alt="Mila Logo" class="size-8 shrink-0" />
|
||||||
<span class="menu-label text-lg font-bold truncate flex-1">
|
<span class="menu-label text-lg font-bold truncate">Mitgliederverwaltung</span>
|
||||||
|
</div>
|
||||||
|
<span class="absolute left-1/2 -translate-x-1/2 text-lg font-bold text-center max-w-[50%] truncate">
|
||||||
{@club_name}
|
{@club_name}
|
||||||
</span>
|
</span>
|
||||||
<form method="post" action={~p"/set_locale"} class="shrink-0">
|
<div class="shrink-0 flex items-center gap-2">
|
||||||
|
<form method="post" action={~p"/set_locale"}>
|
||||||
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
|
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
|
||||||
<select
|
<select
|
||||||
name="locale"
|
name="locale"
|
||||||
|
|
@ -113,10 +209,12 @@ defmodule MvWeb.Layouts do
|
||||||
class="select select-sm focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
class="select select-sm focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
||||||
aria-label={gettext("Select language")}
|
aria-label={gettext("Select language")}
|
||||||
>
|
>
|
||||||
<option value="de" selected={Gettext.get_locale() == "de"}>Deutsch</option>
|
<option value="de" selected={Gettext.get_locale(MvWeb.Gettext) == "de"}>Deutsch</option>
|
||||||
<option value="en" selected={Gettext.get_locale() == "en"}>English</option>
|
<option value="en" selected={Gettext.get_locale(MvWeb.Gettext) == "en"}>English</option>
|
||||||
</select>
|
</select>
|
||||||
</form>
|
</form>
|
||||||
|
<.theme_swap />
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main class="px-4 py-8 sm:px-6">
|
<main class="px-4 py-8 sm:px-6">
|
||||||
<div class="mx-auto space-y-4 max-full">
|
<div class="mx-auto space-y-4 max-full">
|
||||||
|
|
@ -129,12 +227,17 @@ defmodule MvWeb.Layouts do
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper function to get club name from settings
|
# Single settings read for layout; returns club_name and join_form_enabled to avoid duplicate get_settings().
|
||||||
# Falls back to "Mitgliederverwaltung" if settings can't be loaded
|
defp get_layout_settings do
|
||||||
defp get_club_name do
|
|
||||||
case Mv.Membership.get_settings() do
|
case Mv.Membership.get_settings() do
|
||||||
{:ok, settings} -> settings.club_name
|
{:ok, settings} ->
|
||||||
_ -> "Mitgliederverwaltung"
|
%{
|
||||||
|
club_name: settings.club_name || "Mitgliederverwaltung",
|
||||||
|
join_form_enabled: settings.join_form_enabled == true
|
||||||
|
}
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
%{club_name: "Mitgliederverwaltung", join_form_enabled: false}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -162,7 +265,7 @@ defmodule MvWeb.Layouts do
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
class="z-50 toast toast-bottom toast-end flex flex-col gap-2 pointer-events-none"
|
class="z-50 toast toast-bottom toast-end flex flex-col gap-2 pointer-events-none"
|
||||||
>
|
>
|
||||||
<.flash kind={:success} flash={@flash} />
|
<.flash kind={:success} flash={@flash} auto_clear_ms={5000} />
|
||||||
<.flash kind={:warning} flash={@flash} />
|
<.flash kind={:warning} flash={@flash} />
|
||||||
<.flash kind={:info} flash={@flash} />
|
<.flash kind={:info} flash={@flash} />
|
||||||
<.flash kind={:error} flash={@flash} />
|
<.flash kind={:error} flash={@flash} />
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="csrf-token" content={get_csrf_token()} />
|
<meta name="csrf-token" content={get_csrf_token()} />
|
||||||
<link phx-track-static rel="icon" type="image/svg+xml" href={~p"/images/mila.svg"} />
|
<link phx-track-static rel="icon" type="image/svg+xml" href={~p"/images/mila.svg"} />
|
||||||
<.live_title default="Mv" suffix=" · Phoenix Framework">
|
<.live_title default="Mila">
|
||||||
{assigns[:page_title]}
|
{page_title_string(assigns)}
|
||||||
</.live_title>
|
</.live_title>
|
||||||
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
|
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
|
||||||
<script defer phx-track-static type="text/javascript" src={~p"/assets/js/app.js"}>
|
<script defer phx-track-static type="text/javascript" src={~p"/assets/js/app.js"}>
|
||||||
|
|
@ -74,7 +74,7 @@
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
class="z-50 flex flex-col gap-2 toast toast-bottom toast-end"
|
class="z-50 flex flex-col gap-2 toast toast-bottom toast-end"
|
||||||
>
|
>
|
||||||
<.flash id="flash-success-root" kind={:success} flash={@flash} />
|
<.flash id="flash-success-root" kind={:success} flash={@flash} auto_clear_ms={5000} />
|
||||||
<.flash id="flash-warning-root" kind={:warning} flash={@flash} />
|
<.flash id="flash-warning-root" kind={:warning} flash={@flash} />
|
||||||
<.flash id="flash-info-root" kind={:info} flash={@flash} />
|
<.flash id="flash-info-root" kind={:info} flash={@flash} />
|
||||||
<.flash id="flash-error-root" kind={:error} flash={@flash} />
|
<.flash id="flash-error-root" kind={:error} flash={@flash} />
|
||||||
|
|
|
||||||
|
|
@ -251,8 +251,10 @@ defmodule MvWeb.Layouts.Sidebar do
|
||||||
defp sidebar_footer(assigns) do
|
defp sidebar_footer(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<div class="mt-auto p-4 border-t border-base-300 space-y-4">
|
<div class="mt-auto p-4 border-t border-base-300 space-y-4">
|
||||||
<!-- Language Selector (nur expanded) -->
|
<!-- Theme swap + Language selector in one row (theme left, language right when expanded) -->
|
||||||
<form method="post" action={~p"/set_locale"} class="expanded-only">
|
<div class="flex items-center gap-2">
|
||||||
|
<.theme_swap />
|
||||||
|
<form method="post" action={~p"/set_locale"} class="expanded-only flex-1 min-w-0">
|
||||||
<input type="hidden" name="_csrf_token" value={get_csrf_token()} />
|
<input type="hidden" name="_csrf_token" value={get_csrf_token()} />
|
||||||
<select
|
<select
|
||||||
name="locale"
|
name="locale"
|
||||||
|
|
@ -260,12 +262,11 @@ defmodule MvWeb.Layouts.Sidebar do
|
||||||
class="select select-sm w-full focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
class="select select-sm w-full focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
||||||
aria-label={gettext("Select language")}
|
aria-label={gettext("Select language")}
|
||||||
>
|
>
|
||||||
<option value="de" selected={Gettext.get_locale() == "de"}>Deutsch</option>
|
<option value="de" selected={Gettext.get_locale(MvWeb.Gettext) == "de"}>Deutsch</option>
|
||||||
<option value="en" selected={Gettext.get_locale() == "en"}>English</option>
|
<option value="en" selected={Gettext.get_locale(MvWeb.Gettext) == "en"}>English</option>
|
||||||
</select>
|
</select>
|
||||||
</form>
|
</form>
|
||||||
<!-- Theme Toggle (immer sichtbar) -->
|
</div>
|
||||||
<.theme_toggle />
|
|
||||||
<!-- User Menu (nur wenn current_user existiert) -->
|
<!-- User Menu (nur wenn current_user existiert) -->
|
||||||
<%= if @current_user do %>
|
<%= if @current_user do %>
|
||||||
<.user_menu current_user={@current_user} />
|
<.user_menu current_user={@current_user} />
|
||||||
|
|
@ -274,29 +275,6 @@ defmodule MvWeb.Layouts.Sidebar do
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
defp theme_toggle(assigns) do
|
|
||||||
~H"""
|
|
||||||
<label
|
|
||||||
class="flex items-center gap-2 cursor-pointer justify-center focus-within:outline-none focus-within:ring-2 focus-within:ring-primary focus-within:ring-offset-2"
|
|
||||||
aria-label={gettext("Toggle dark mode")}
|
|
||||||
>
|
|
||||||
<.icon name="hero-sun" class="size-5" aria-hidden="true" />
|
|
||||||
<div id="theme-toggle" phx-update="ignore">
|
|
||||||
<input
|
|
||||||
id="theme-toggle-input"
|
|
||||||
type="checkbox"
|
|
||||||
class="toggle toggle-sm focus:outline-none"
|
|
||||||
data-theme-toggle
|
|
||||||
onchange="window.dispatchEvent(new CustomEvent('phx:set-theme',{detail:{theme:this.checked?'dark':'light'}}))"
|
|
||||||
aria-label={gettext("Toggle dark mode")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<.icon name="hero-moon" class="size-5" aria-hidden="true" />
|
|
||||||
</label>
|
|
||||||
"""
|
|
||||||
end
|
|
||||||
|
|
||||||
attr :current_user, :map, default: nil, doc: "The current user"
|
attr :current_user, :map, default: nil, doc: "The current user"
|
||||||
|
|
||||||
defp user_menu(assigns) do
|
defp user_menu(assigns) do
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,23 @@ defmodule MvWeb.AuthController do
|
||||||
use AshAuthentication.Phoenix.Controller
|
use AshAuthentication.Phoenix.Controller
|
||||||
|
|
||||||
alias Mv.Accounts.User.Errors.PasswordVerificationRequired
|
alias Mv.Accounts.User.Errors.PasswordVerificationRequired
|
||||||
|
alias Mv.Config
|
||||||
|
|
||||||
def success(conn, activity, user, _token) do
|
def success(conn, {:password, :sign_in} = _activity, user, token) do
|
||||||
|
if Config.oidc_only?() do
|
||||||
|
conn
|
||||||
|
|> put_flash(:error, gettext("Only sign-in via Single Sign-On (SSO) is allowed."))
|
||||||
|
|> redirect(to: sign_in_path_after_oidc_failure())
|
||||||
|
else
|
||||||
|
success_continue(conn, {:password, :sign_in}, user, token)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def success(conn, activity, user, token) do
|
||||||
|
success_continue(conn, activity, user, token)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp success_continue(conn, activity, user, _token) do
|
||||||
return_to = get_session(conn, :return_to) || ~p"/"
|
return_to = get_session(conn, :return_to) || ~p"/"
|
||||||
|
|
||||||
message =
|
message =
|
||||||
|
|
@ -134,7 +149,7 @@ defmodule MvWeb.AuthController do
|
||||||
_ ->
|
_ ->
|
||||||
conn
|
conn
|
||||||
|> put_flash(:error, gettext("Unable to authenticate with OIDC. Please try again."))
|
|> put_flash(:error, gettext("Unable to authenticate with OIDC. Please try again."))
|
||||||
|> redirect(to: ~p"/sign-in")
|
|> redirect(to: sign_in_path_after_oidc_failure())
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -148,7 +163,7 @@ defmodule MvWeb.AuthController do
|
||||||
:error,
|
:error,
|
||||||
gettext("The authentication server is currently unavailable. Please try again later.")
|
gettext("The authentication server is currently unavailable. Please try again later.")
|
||||||
)
|
)
|
||||||
|> redirect(to: ~p"/sign-in")
|
|> redirect(to: sign_in_path_after_oidc_failure())
|
||||||
end
|
end
|
||||||
|
|
||||||
# Handle Assent invalid response errors (configuration or malformed responses)
|
# Handle Assent invalid response errors (configuration or malformed responses)
|
||||||
|
|
@ -161,7 +176,7 @@ defmodule MvWeb.AuthController do
|
||||||
:error,
|
:error,
|
||||||
gettext("Authentication configuration error. Please contact the administrator.")
|
gettext("Authentication configuration error. Please contact the administrator.")
|
||||||
)
|
)
|
||||||
|> redirect(to: ~p"/sign-in")
|
|> redirect(to: sign_in_path_after_oidc_failure())
|
||||||
end
|
end
|
||||||
|
|
||||||
# Catch-all clause for any other error types
|
# Catch-all clause for any other error types
|
||||||
|
|
@ -171,7 +186,7 @@ defmodule MvWeb.AuthController do
|
||||||
|
|
||||||
conn
|
conn
|
||||||
|> put_flash(:error, gettext("Unable to authenticate with OIDC. Please try again."))
|
|> put_flash(:error, gettext("Unable to authenticate with OIDC. Please try again."))
|
||||||
|> redirect(to: ~p"/sign-in")
|
|> redirect(to: sign_in_path_after_oidc_failure())
|
||||||
end
|
end
|
||||||
|
|
||||||
# Handle generic AuthenticationFailed errors
|
# Handle generic AuthenticationFailed errors
|
||||||
|
|
@ -211,10 +226,14 @@ defmodule MvWeb.AuthController do
|
||||||
|
|
||||||
conn
|
conn
|
||||||
|> put_flash(:error, error_message)
|
|> put_flash(:error, error_message)
|
||||||
|> redirect(to: ~p"/sign-in")
|
|> redirect(to: sign_in_path_after_oidc_failure())
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Path used when redirecting to sign-in after an OIDC failure. The query param tells
|
||||||
|
# OidcOnlySignInRedirect to show the sign-in page instead of redirecting back to OIDC (avoids loop).
|
||||||
|
defp sign_in_path_after_oidc_failure, do: "/sign-in?oidc_failed=1"
|
||||||
|
|
||||||
# Extract meaningful error message from Ash errors
|
# Extract meaningful error message from Ash errors
|
||||||
defp extract_meaningful_error_message(errors) do
|
defp extract_meaningful_error_message(errors) do
|
||||||
# Look for specific error messages in InvalidAttribute errors
|
# Look for specific error messages in InvalidAttribute errors
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,14 @@ defmodule MvWeb.JoinConfirmController do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Handles GET /confirm_join/:token for the public join flow (double opt-in).
|
Handles GET /confirm_join/:token for the public join flow (double opt-in).
|
||||||
|
|
||||||
Calls a configurable callback (default Mv.Membership) so tests can stub the
|
Renders a full HTML page with public header and hero layout (success, expired,
|
||||||
dependency. Public route; no authentication required.
|
or invalid). Calls a configurable callback (default Mv.Membership) so tests can
|
||||||
|
stub the dependency. Public route; no authentication required.
|
||||||
"""
|
"""
|
||||||
use MvWeb, :controller
|
use MvWeb, :controller
|
||||||
|
|
||||||
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
||||||
def confirm(conn, %{"token" => token}) when is_binary(token) do
|
def confirm(conn, %{"token" => token}) when is_binary(token) do
|
||||||
callback = Application.get_env(:mv, :join_confirm_callback, Mv.Membership)
|
callback = Application.get_env(:mv, :join_confirm_callback, Mv.Membership)
|
||||||
|
|
||||||
|
|
@ -26,20 +29,36 @@ defmodule MvWeb.JoinConfirmController do
|
||||||
|
|
||||||
defp success_response(conn) do
|
defp success_response(conn) do
|
||||||
conn
|
conn
|
||||||
|> put_resp_content_type("text/html")
|
|> assign_confirm_assigns(:success)
|
||||||
|> send_resp(200, gettext("Thank you, we have received your request."))
|
|> put_view(MvWeb.JoinConfirmHTML)
|
||||||
|
|> render("confirm.html")
|
||||||
end
|
end
|
||||||
|
|
||||||
defp expired_response(conn) do
|
defp expired_response(conn) do
|
||||||
conn
|
conn
|
||||||
|> put_resp_content_type("text/html")
|
|> assign_confirm_assigns(:expired)
|
||||||
|> send_resp(200, gettext("This link has expired. Please submit the form again."))
|
|> put_view(MvWeb.JoinConfirmHTML)
|
||||||
|
|> render("confirm.html")
|
||||||
end
|
end
|
||||||
|
|
||||||
defp invalid_response(conn) do
|
defp invalid_response(conn) do
|
||||||
conn
|
conn
|
||||||
|> put_resp_content_type("text/html")
|
|
||||||
|> put_status(404)
|
|> put_status(404)
|
||||||
|> send_resp(404, gettext("Invalid or expired link."))
|
|> assign_confirm_assigns(:invalid)
|
||||||
|
|> put_view(MvWeb.JoinConfirmHTML)
|
||||||
|
|> render("confirm.html")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp assign_confirm_assigns(conn, result) do
|
||||||
|
page_title = page_title_for_result(result)
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> assign(:result, result)
|
||||||
|
|> assign(:page_title, page_title)
|
||||||
|
|> assign(:flash, conn.assigns[:flash] || conn.flash || %{})
|
||||||
|
end
|
||||||
|
|
||||||
|
defp page_title_for_result(:success), do: gettext("Join confirmation")
|
||||||
|
defp page_title_for_result(:expired), do: gettext("Link expired")
|
||||||
|
defp page_title_for_result(:invalid), do: gettext("Invalid link")
|
||||||
end
|
end
|
||||||
|
|
|
||||||
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 MvWeb, :controller
|
||||||
|
|
||||||
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
||||||
def home(conn, _params) do
|
def home(conn, _params) do
|
||||||
render(conn, :home)
|
conn
|
||||||
|
|> assign(:page_title, gettext("Home"))
|
||||||
|
|> render(:home)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
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 """
|
@doc """
|
||||||
Sends the join confirmation email to the given address with the confirmation link.
|
Sends the join confirmation email to the given address with the confirmation link.
|
||||||
|
|
||||||
Called from the domain after a JoinRequest is created (submit flow).
|
Uses the same SMTP configuration as the test mail (Settings or boot ENV) via
|
||||||
|
`Mailer.deliver/2` with `Mailer.smtp_config/0` for consistency.
|
||||||
|
|
||||||
|
Called from the domain after a JoinRequest is created (submit flow) or when
|
||||||
|
resending to an existing pending request.
|
||||||
|
|
||||||
|
## Options
|
||||||
|
- `:resend` - If true, adds a short note that the link is being sent again for an existing request.
|
||||||
|
|
||||||
Returns `{:ok, email}` on success, `{:error, reason}` on delivery failure.
|
Returns `{:ok, email}` on success, `{:error, reason}` on delivery failure.
|
||||||
Callers should log errors and may still return success for the overall operation
|
|
||||||
(e.g. join request created) so the user is not shown a generic error when only
|
|
||||||
the email failed.
|
|
||||||
"""
|
"""
|
||||||
def send(email_address, token) when is_binary(email_address) and is_binary(token) do
|
def send(email_address, token, opts \\ [])
|
||||||
|
when is_binary(email_address) and is_binary(token) do
|
||||||
confirm_url = url(~p"/confirm_join/#{token}")
|
confirm_url = url(~p"/confirm_join/#{token}")
|
||||||
subject = gettext("Confirm your membership request")
|
subject = gettext("Confirm your membership request")
|
||||||
|
|
||||||
|
|
@ -29,15 +35,18 @@ defmodule MvWeb.Emails.JoinConfirmationEmail do
|
||||||
confirm_url: confirm_url,
|
confirm_url: confirm_url,
|
||||||
subject: subject,
|
subject: subject,
|
||||||
app_name: Mailer.mail_from() |> elem(0),
|
app_name: Mailer.mail_from() |> elem(0),
|
||||||
locale: Gettext.get_locale(MvWeb.Gettext)
|
locale: Gettext.get_locale(MvWeb.Gettext),
|
||||||
|
resend: Keyword.get(opts, :resend, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
email =
|
||||||
new()
|
new()
|
||||||
|> from(Mailer.mail_from())
|
|> from(Mailer.mail_from())
|
||||||
|> to(email_address)
|
|> to(email_address)
|
||||||
|> subject(subject)
|
|> subject(subject)
|
||||||
|> put_view(MvWeb.EmailsView)
|
|> put_view(MvWeb.EmailsView)
|
||||||
|> render_body("join_confirmation.html", assigns)
|
|> render_body("join_confirmation.html", assigns)
|
||||||
|> Mailer.deliver()
|
|
||||||
|
Mailer.deliver(email, Mailer.smtp_config())
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ defmodule MvWeb.Helpers.DateFormatter do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Centralized date formatting helper for the application.
|
Centralized date formatting helper for the application.
|
||||||
Formats dates in European format (dd.mm.yyyy).
|
Formats dates in European format (dd.mm.yyyy).
|
||||||
|
DateTime can be shown in UTC or in a given IANA timezone (e.g. from browser).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
use Gettext, backend: MvWeb.Gettext
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
@ -28,19 +29,40 @@ defmodule MvWeb.Helpers.DateFormatter do
|
||||||
@doc """
|
@doc """
|
||||||
Formats a DateTime struct to European format (dd.mm.yyyy HH:MM).
|
Formats a DateTime struct to European format (dd.mm.yyyy HH:MM).
|
||||||
|
|
||||||
|
When `timezone` is a valid IANA timezone string (e.g. from the browser),
|
||||||
|
the datetime is converted to that zone before formatting. When `timezone` is
|
||||||
|
nil or invalid, the datetime is formatted in UTC.
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
iex> MvWeb.Helpers.DateFormatter.format_datetime(~U[2024-03-15 10:30:00Z])
|
iex> MvWeb.Helpers.DateFormatter.format_datetime(~U[2024-03-15 10:30:00Z])
|
||||||
"15.03.2024 10:30"
|
"15.03.2024 10:30"
|
||||||
|
|
||||||
|
iex> MvWeb.Helpers.DateFormatter.format_datetime(~U[2024-03-15 10:30:00Z], "Europe/Berlin")
|
||||||
|
"15.03.2024 11:30"
|
||||||
|
|
||||||
iex> MvWeb.Helpers.DateFormatter.format_datetime(nil)
|
iex> MvWeb.Helpers.DateFormatter.format_datetime(nil)
|
||||||
""
|
""
|
||||||
"""
|
"""
|
||||||
def format_datetime(%DateTime{} = dt) do
|
def format_datetime(%DateTime{} = dt), do: format_datetime(dt, nil)
|
||||||
Calendar.strftime(dt, "%d.%m.%Y %H:%M")
|
def format_datetime(nil), do: ""
|
||||||
|
def format_datetime(_), do: "Invalid datetime"
|
||||||
|
|
||||||
|
def format_datetime(%DateTime{} = dt, nil), do: format_datetime_utc(dt)
|
||||||
|
def format_datetime(%DateTime{} = dt, ""), do: format_datetime_utc(dt)
|
||||||
|
|
||||||
|
def format_datetime(%DateTime{} = dt, tz) when is_binary(tz) do
|
||||||
|
case DateTime.shift_zone(dt, tz, Tz.TimeZoneDatabase) do
|
||||||
|
{:ok, shifted} -> Calendar.strftime(shifted, "%d.%m.%Y %H:%M")
|
||||||
|
{:error, _} -> format_datetime_utc(dt)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def format_datetime(nil), do: ""
|
def format_datetime(nil, _timezone), do: ""
|
||||||
|
|
||||||
def format_datetime(_), do: "Invalid datetime"
|
def format_datetime(_, _timezone), do: "Invalid datetime"
|
||||||
|
|
||||||
|
defp format_datetime_utc(%DateTime{} = dt) do
|
||||||
|
Calendar.strftime(dt, "%d.%m.%Y %H:%M")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
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
|
defmodule MvWeb.SignInLive do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Custom sign-in page with language selector and conditional Single Sign-On button.
|
Custom sign-in page with public header and hero layout (same as Join/Join Confirm).
|
||||||
|
|
||||||
- Renders a language selector (same pattern as LinkOidcAccountLive).
|
Uses Layouts.public_page (no sidebar, no app-layout hooks). Wraps the AshAuthentication
|
||||||
- Wraps the default AshAuthentication SignIn component in a container with
|
SignIn component in a hero section. Container has data-oidc-configured so CSS can hide
|
||||||
`data-oidc-configured` so that CSS can hide the SSO button when OIDC is not configured.
|
the SSO button when OIDC is not configured.
|
||||||
|
|
||||||
|
Keeps `use Phoenix.LiveView` (not MvWeb :live_view) so AshAuthentication's sign_in_route
|
||||||
|
live_session on_mount chain is not mixed with LiveHelpers hooks.
|
||||||
|
|
||||||
|
## Locale overrides
|
||||||
|
`MvWeb.AuthOverridesDE` is prepended to the overrides list when the locale is "de",
|
||||||
|
providing static German strings for components that do not use `_gettext` internally
|
||||||
|
(e.g. HorizontalRule renders its `:text` override directly).
|
||||||
"""
|
"""
|
||||||
use Phoenix.LiveView
|
use Phoenix.LiveView
|
||||||
use Gettext, backend: MvWeb.Gettext
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
||||||
alias AshAuthentication.Phoenix.Components
|
alias AshAuthentication.Phoenix.Components
|
||||||
alias Mv.Config
|
alias Mv.Config
|
||||||
|
alias Mv.Membership
|
||||||
|
alias MvWeb.{AuthOverridesDE, AuthOverridesRegistrationDisabled, Layouts}
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, session, socket) do
|
def mount(_params, session, socket) do
|
||||||
overrides =
|
|
||||||
session
|
|
||||||
|> Map.get("overrides", [AshAuthentication.Phoenix.Overrides.Default])
|
|
||||||
|
|
||||||
# Locale: same fallback as LiveUserAuth so config :default_locale (e.g. "en" in test) is respected
|
# Locale: same fallback as LiveUserAuth so config :default_locale (e.g. "en" in test) is respected
|
||||||
locale =
|
locale = session["locale"] || Application.get_env(:mv, :default_locale, "de")
|
||||||
session["locale"] || Application.get_env(:mv, :default_locale, "de")
|
|
||||||
|
|
||||||
|
# Set both backend-specific and global locale so Gettext.get_locale/0 and
|
||||||
|
# Gettext.get_locale/1 both return the correct value (important for the
|
||||||
|
# language-selector `selected` attribute in Layouts.public_page).
|
||||||
Gettext.put_locale(MvWeb.Gettext, locale)
|
Gettext.put_locale(MvWeb.Gettext, locale)
|
||||||
|
Gettext.put_locale(locale)
|
||||||
|
|
||||||
|
# Prepend DE-specific overrides when locale is German so that components
|
||||||
|
# without _gettext support (e.g. HorizontalRule) still render in German.
|
||||||
|
base_overrides = Map.get(session, "overrides", [AshAuthentication.Phoenix.Overrides.Default])
|
||||||
|
locale_overrides = if locale == "de", do: [AuthOverridesDE], else: []
|
||||||
|
|
||||||
|
registration_disabled =
|
||||||
|
if session["registration_enabled"] == false,
|
||||||
|
do: [AuthOverridesRegistrationDisabled],
|
||||||
|
else: []
|
||||||
|
|
||||||
|
# When registration is disabled: hide register link (register_path: nil) and hide
|
||||||
|
# "Need an account?" toggle (override register_toggle_text: nil so it takes precedence).
|
||||||
|
overrides = registration_disabled ++ locale_overrides ++ base_overrides
|
||||||
|
|
||||||
|
register_path =
|
||||||
|
if session["registration_enabled"] == false, do: nil, else: session["register_path"]
|
||||||
|
|
||||||
|
# Club name and page title for browser tab (root layout: Mila · Club · Page)
|
||||||
|
club_name =
|
||||||
|
case Membership.get_settings() do
|
||||||
|
{:ok, settings} when is_binary(settings.club_name) -> settings.club_name
|
||||||
|
_ -> nil
|
||||||
|
end
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|
|
@ -30,18 +63,19 @@ defmodule MvWeb.SignInLive do
|
||||||
|> assign_new(:otp_app, fn -> nil end)
|
|> assign_new(:otp_app, fn -> nil end)
|
||||||
|> assign(:path, session["path"] || "/")
|
|> assign(:path, session["path"] || "/")
|
||||||
|> assign(:reset_path, session["reset_path"])
|
|> assign(:reset_path, session["reset_path"])
|
||||||
|> assign(:register_path, session["register_path"])
|
|> assign(:register_path, register_path)
|
||||||
|> assign(:current_tenant, session["tenant"])
|
|> assign(:current_tenant, session["tenant"])
|
||||||
|> assign(:resources, session["resources"])
|
|> assign(:resources, session["resources"])
|
||||||
|> assign(:context, session["context"] || %{})
|
|> assign(:context, session["context"] || %{})
|
||||||
|> assign(:auth_routes_prefix, session["auth_routes_prefix"])
|
|> assign(:auth_routes_prefix, session["auth_routes_prefix"])
|
||||||
|> assign(:gettext_fn, session["gettext_fn"])
|
|> assign(:gettext_fn, session["gettext_fn"])
|
||||||
|> assign(:live_action, :sign_in)
|
|> assign_new(:live_action, fn -> :sign_in end)
|
||||||
|> assign(:oidc_configured, Config.oidc_configured?())
|
|> assign(:oidc_configured, Config.oidc_configured?())
|
||||||
|> assign(:oidc_only, Config.oidc_only?())
|
|> assign(:oidc_only, Config.oidc_only?())
|
||||||
|> assign(:root_class, "grid h-screen place-items-center bg-base-100")
|
|
||||||
|> assign(:sign_in_id, "sign-in")
|
|> assign(:sign_in_id, "sign-in")
|
||||||
|> assign(:locale, locale)
|
|> assign(:locale, locale)
|
||||||
|
|> assign(:club_name, club_name)
|
||||||
|
|> Layouts.assign_page_title(gettext("Sign in"))
|
||||||
|
|
||||||
{:ok, socket}
|
{:ok, socket}
|
||||||
end
|
end
|
||||||
|
|
@ -54,34 +88,23 @@ defmodule MvWeb.SignInLive do
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<main
|
<Layouts.public_page flash={@flash}>
|
||||||
|
<div class="max-w-4xl mx-auto">
|
||||||
|
<div
|
||||||
|
class="hero min-h-[60vh] bg-base-200 rounded-lg"
|
||||||
id="sign-in-page"
|
id="sign-in-page"
|
||||||
role="main"
|
role="main"
|
||||||
class={@root_class}
|
|
||||||
data-oidc-configured={to_string(@oidc_configured)}
|
data-oidc-configured={to_string(@oidc_configured)}
|
||||||
data-oidc-only={to_string(@oidc_only)}
|
data-oidc-only={to_string(@oidc_only)}
|
||||||
data-locale={@locale}
|
data-locale={@locale}
|
||||||
>
|
>
|
||||||
<h1 class="sr-only">{dgettext("auth", "Sign in")}</h1>
|
<div class="hero-content flex-col items-start text-left">
|
||||||
<%!-- Language selector --%>
|
<div class="w-full max-w-md">
|
||||||
<nav
|
<h1 class="text-xl font-semibold leading-8">
|
||||||
aria-label={dgettext("auth", "Language selection")}
|
{if @live_action == :register,
|
||||||
class="absolute top-4 right-4 flex justify-end z-10"
|
do: dgettext("auth", "Register"),
|
||||||
>
|
else: dgettext("auth", "Sign in")}
|
||||||
<form method="post" action="/set_locale" class="text-sm">
|
</h1>
|
||||||
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
|
|
||||||
<select
|
|
||||||
name="locale"
|
|
||||||
onchange="this.form.submit()"
|
|
||||||
class="select select-sm select-bordered bg-base-100"
|
|
||||||
aria-label={dgettext("auth", "Select language")}
|
|
||||||
>
|
|
||||||
<option value="de" selected={@locale == "de"}>Deutsch</option>
|
|
||||||
<option value="en" selected={@locale == "en"}>English</option>
|
|
||||||
</select>
|
|
||||||
</form>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<.live_component
|
<.live_component
|
||||||
module={Components.SignIn}
|
module={Components.SignIn}
|
||||||
otp_app={@otp_app}
|
otp_app={@otp_app}
|
||||||
|
|
@ -97,7 +120,11 @@ defmodule MvWeb.SignInLive do
|
||||||
context={@context}
|
context={@context}
|
||||||
gettext_fn={@gettext_fn}
|
gettext_fn={@gettext_fn}
|
||||||
/>
|
/>
|
||||||
</main>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layouts.public_page>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ defmodule MvWeb.DatafieldsLive do
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, gettext("Datafields"))
|
|> Layouts.assign_page_title(gettext("Datafields"))
|
||||||
|> assign(:settings, settings)
|
|> assign(:settings, settings)
|
||||||
|> assign(:active_editing_section, nil)
|
|> assign(:active_editing_section, nil)
|
||||||
|> assign(:custom_field_delete_modal_open, false)}
|
|> assign(:custom_field_delete_modal_open, false)}
|
||||||
|
|
@ -50,7 +50,7 @@ defmodule MvWeb.DatafieldsLive do
|
||||||
~H"""
|
~H"""
|
||||||
<Layouts.app flash={@flash} current_user={@current_user} club_name={@settings.club_name}>
|
<Layouts.app flash={@flash} current_user={@current_user} club_name={@settings.club_name}>
|
||||||
<.header>
|
<.header>
|
||||||
{gettext("Datafields")}
|
{@content_title}
|
||||||
<:subtitle>
|
<:subtitle>
|
||||||
{gettext(
|
{gettext(
|
||||||
"Configure which data you want to save for your members. Define individual datafields."
|
"Configure which data you want to save for your members. Define individual datafields."
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,15 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
|
|
||||||
## Settings
|
## Settings
|
||||||
- `club_name` - The name of the association/club (required)
|
- `club_name` - The name of the association/club (required)
|
||||||
|
- `registration_enabled` - Whether direct registration via /register is allowed
|
||||||
- `join_form_enabled` - Whether the public /join page is active
|
- `join_form_enabled` - Whether the public /join page is active
|
||||||
- `join_form_field_ids` - Ordered list of field IDs shown on the join form
|
- `join_form_field_ids` - Ordered list of field IDs shown on the join form
|
||||||
- `join_form_field_required` - Map of field ID => required boolean
|
- `join_form_field_required` - Map of field ID => required boolean
|
||||||
|
|
||||||
## Events
|
## Events
|
||||||
- `validate` / `save` - Club settings form
|
- `validate` / `save` - Club settings form
|
||||||
|
- `toggle_registration_enabled` - Enable/disable direct registration (/register)
|
||||||
|
- `toggle_oidc_only` - Enable/disable OIDC-only sign-in (immediate, outside OIDC form)
|
||||||
- `toggle_join_form_enabled` - Enable/disable the join form
|
- `toggle_join_form_enabled` - Enable/disable the join form
|
||||||
- `add_join_form_field` / `remove_join_form_field` - Manage join form fields
|
- `add_join_form_field` / `remove_join_form_field` - Manage join form fields
|
||||||
- `toggle_join_form_field_required` - Toggle required flag per field
|
- `toggle_join_form_field_required` - Toggle required flag per field
|
||||||
|
|
@ -54,11 +57,14 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
actor = MvWeb.LiveHelpers.current_actor(socket)
|
actor = MvWeb.LiveHelpers.current_actor(socket)
|
||||||
custom_fields = load_custom_fields(actor)
|
custom_fields = load_custom_fields(actor)
|
||||||
|
|
||||||
|
environment = Application.get_env(:mv, :environment, :dev)
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, gettext("Settings"))
|
|> Layouts.assign_page_title(gettext("Basic settings"))
|
||||||
|> assign(:settings, settings)
|
|> assign(:settings, settings)
|
||||||
|> assign(:locale, locale)
|
|> assign(:locale, locale)
|
||||||
|
|> assign(:environment, environment)
|
||||||
|> assign(:vereinfacht_env_configured, Mv.Config.vereinfacht_env_configured?())
|
|> assign(:vereinfacht_env_configured, Mv.Config.vereinfacht_env_configured?())
|
||||||
|> assign(:vereinfacht_api_url_env_set, Mv.Config.vereinfacht_api_url_env_set?())
|
|> assign(:vereinfacht_api_url_env_set, Mv.Config.vereinfacht_api_url_env_set?())
|
||||||
|> assign(:vereinfacht_api_key_env_set, Mv.Config.vereinfacht_api_key_env_set?())
|
|> assign(:vereinfacht_api_key_env_set, Mv.Config.vereinfacht_api_key_env_set?())
|
||||||
|
|
@ -75,9 +81,24 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
|> assign(:oidc_admin_group_name_env_set, Mv.Config.oidc_admin_group_name_env_set?())
|
|> assign(:oidc_admin_group_name_env_set, Mv.Config.oidc_admin_group_name_env_set?())
|
||||||
|> assign(:oidc_groups_claim_env_set, Mv.Config.oidc_groups_claim_env_set?())
|
|> assign(:oidc_groups_claim_env_set, Mv.Config.oidc_groups_claim_env_set?())
|
||||||
|> assign(:oidc_only_env_set, Mv.Config.oidc_only_env_set?())
|
|> assign(:oidc_only_env_set, Mv.Config.oidc_only_env_set?())
|
||||||
|
|> assign(:oidc_only, Mv.Config.oidc_only?())
|
||||||
|> assign(:oidc_configured, Mv.Config.oidc_configured?())
|
|> assign(:oidc_configured, Mv.Config.oidc_configured?())
|
||||||
|> assign(:oidc_client_secret_set, present?(settings.oidc_client_secret))
|
|> assign(:oidc_client_secret_set, Mv.Config.oidc_client_secret_set?())
|
||||||
|
|> assign(:registration_enabled, settings.registration_enabled != false)
|
||||||
|
|> assign(:smtp_env_configured, Mv.Config.smtp_env_configured?())
|
||||||
|
|> assign(:smtp_host_env_set, Mv.Config.smtp_host_env_set?())
|
||||||
|
|> assign(:smtp_port_env_set, Mv.Config.smtp_port_env_set?())
|
||||||
|
|> assign(:smtp_username_env_set, Mv.Config.smtp_username_env_set?())
|
||||||
|
|> assign(:smtp_password_env_set, Mv.Config.smtp_password_env_set?())
|
||||||
|
|> assign(:smtp_ssl_env_set, Mv.Config.smtp_ssl_env_set?())
|
||||||
|
|> assign(:smtp_from_name_env_set, Mv.Config.mail_from_name_env_set?())
|
||||||
|
|> assign(:smtp_from_email_env_set, Mv.Config.mail_from_email_env_set?())
|
||||||
|
|> assign(:smtp_password_set, present?(Mv.Config.smtp_password()))
|
||||||
|
|> assign(:smtp_configured, Mv.Config.smtp_configured?())
|
||||||
|
|> assign(:smtp_test_result, nil)
|
||||||
|
|> assign(:smtp_test_to_email, "")
|
||||||
|> assign_join_form_state(settings, custom_fields)
|
|> assign_join_form_state(settings, custom_fields)
|
||||||
|
|> assign(:join_url, url(socket.endpoint, ~p"/join"))
|
||||||
|> assign_form()
|
|> assign_form()
|
||||||
|
|
||||||
{:ok, socket}
|
{:ok, socket}
|
||||||
|
|
@ -93,12 +114,13 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
~H"""
|
~H"""
|
||||||
<Layouts.app flash={@flash} current_user={@current_user} club_name={@settings.club_name}>
|
<Layouts.app flash={@flash} current_user={@current_user} club_name={@settings.club_name}>
|
||||||
<.header>
|
<.header>
|
||||||
{gettext("Settings")}
|
{gettext("Basic settings")}
|
||||||
<:subtitle>
|
<:subtitle>
|
||||||
{gettext("Manage global settings for the association.")}
|
{gettext("Manage global settings for the association.")}
|
||||||
</:subtitle>
|
</:subtitle>
|
||||||
</.header>
|
</.header>
|
||||||
|
|
||||||
|
<div class="mt-6 space-y-6 max-w-4xl px-4">
|
||||||
<%!-- Club Settings Section --%>
|
<%!-- Club Settings Section --%>
|
||||||
<.form_section title={gettext("Club Settings")}>
|
<.form_section title={gettext("Club Settings")}>
|
||||||
<.form for={@form} id="settings-form" phx-change="validate" phx-submit="save">
|
<.form for={@form} id="settings-form" phx-change="validate" phx-submit="save">
|
||||||
|
|
@ -119,7 +141,9 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
<%!-- Join Form Section (Beitrittsformular) --%>
|
<%!-- Join Form Section (Beitrittsformular) --%>
|
||||||
<.form_section title={gettext("Join Form")}>
|
<.form_section title={gettext("Join Form")}>
|
||||||
<p class="text-sm text-base-content/70 mb-4">
|
<p class="text-sm text-base-content/70 mb-4">
|
||||||
{gettext("Configure the public join form that allows new members to submit a join request.")}
|
{gettext(
|
||||||
|
"Configure the public join form that allows new members to submit a join request."
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<%!-- Enable/disable --%>
|
<%!-- Enable/disable --%>
|
||||||
|
|
@ -137,22 +161,34 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%!-- Board approval (future feature) --%>
|
<div :if={@join_form_enabled}>
|
||||||
<div class="flex items-center gap-3 mb-6">
|
<%!-- Copyable join page link (below checkbox, above field list) --%>
|
||||||
|
<div class="mb-4 p-3 rounded-lg border border-base-300 bg-base-200/50">
|
||||||
|
<p class="text-sm text-base-content/70 mb-2">
|
||||||
|
{gettext("Link to the public join page (share this with applicants):")}
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="text"
|
||||||
id="join-form-board-approval-checkbox"
|
readonly
|
||||||
class="checkbox checkbox-sm"
|
value={@join_url}
|
||||||
checked={false}
|
class="input input-bordered input-sm flex-1 min-w-0 font-mono text-sm"
|
||||||
disabled
|
aria-label={gettext("Join page URL")}
|
||||||
aria-label={gettext("Board approval required (in development)")}
|
|
||||||
/>
|
/>
|
||||||
<label for="join-form-board-approval-checkbox" class="text-base-content/60 font-medium">
|
<.button
|
||||||
{gettext("Board approval required (in development)")}
|
variant="secondary"
|
||||||
</label>
|
size="sm"
|
||||||
|
id="copy-join-url-btn"
|
||||||
|
phx-hook="CopyToClipboard"
|
||||||
|
phx-click="copy_join_url"
|
||||||
|
aria-label={gettext("Copy join page URL")}
|
||||||
|
>
|
||||||
|
<.icon name="hero-clipboard-document" class="size-4" />
|
||||||
|
{gettext("Copy")}
|
||||||
|
</.button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div :if={@join_form_enabled}>
|
|
||||||
<%!-- Field list header + Add button (left-aligned) --%>
|
<%!-- Field list header + Add button (left-aligned) --%>
|
||||||
<h3 class="font-medium mb-3">{gettext("Fields on the join form")}</h3>
|
<h3 class="font-medium mb-3">{gettext("Fields on the join form")}</h3>
|
||||||
<div class="relative mb-3 w-fit" phx-click-away="hide_add_field_dropdown">
|
<div class="relative mb-3 w-fit" phx-click-away="hide_add_field_dropdown">
|
||||||
|
|
@ -225,7 +261,7 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<%!-- Fields table (compact width, reorderable) --%>
|
<%!-- Fields table (compact width, reorderable) --%>
|
||||||
<div :if={not Enum.empty?(@join_form_fields)} class="mb-4 max-w-2xl">
|
<div :if={not Enum.empty?(@join_form_fields)} class="mb-4">
|
||||||
<.sortable_table
|
<.sortable_table
|
||||||
id="join-form-fields-table"
|
id="join-form-fields-table"
|
||||||
rows={@join_form_fields}
|
rows={@join_form_fields}
|
||||||
|
|
@ -235,7 +271,11 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
<:col :let={field} label={gettext("Field")} class="min-w-[14rem]">
|
<:col :let={field} label={gettext("Field")} class="min-w-[14rem]">
|
||||||
{field.label}
|
{field.label}
|
||||||
</:col>
|
</:col>
|
||||||
<:col :let={field} label={gettext("Required")} class="w-24 max-w-[9.375rem] text-center">
|
<:col
|
||||||
|
:let={field}
|
||||||
|
label={gettext("Required")}
|
||||||
|
class="w-24 max-w-[9.375rem] text-center"
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="checkbox checkbox-sm"
|
class="checkbox checkbox-sm"
|
||||||
|
|
@ -269,6 +309,180 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</.form_section>
|
</.form_section>
|
||||||
|
<%!-- SMTP / E-Mail Section --%>
|
||||||
|
<.form_section title={gettext("SMTP / E-Mail")}>
|
||||||
|
<%= if @smtp_env_configured do %>
|
||||||
|
<p class="text-sm text-base-content/70 mb-4">
|
||||||
|
{gettext("Some values are set via environment variables. Those fields are read-only.")}
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= if @environment == :prod and not @smtp_configured do %>
|
||||||
|
<div class="mb-4 flex items-start gap-2 p-3 rounded-lg border border-warning bg-warning/10 text-warning-aa text-sm">
|
||||||
|
<.icon name="hero-exclamation-triangle" class="size-5 shrink-0 mt-0.5" />
|
||||||
|
<span>
|
||||||
|
{gettext(
|
||||||
|
"SMTP is not configured. Transactional emails (join confirmation, password reset, etc.) will not be delivered reliably."
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<.form for={@form} id="smtp-form" phx-change="validate" phx-submit="save">
|
||||||
|
<div class="">
|
||||||
|
<div class="grid grid-cols-1 gap-4 lg:grid-cols-[2fr_5rem_1fr]">
|
||||||
|
<.input
|
||||||
|
field={@form[:smtp_host]}
|
||||||
|
type="text"
|
||||||
|
label={gettext("Host")}
|
||||||
|
disabled={@smtp_host_env_set}
|
||||||
|
placeholder={
|
||||||
|
if(@smtp_host_env_set,
|
||||||
|
do: gettext("From SMTP_HOST"),
|
||||||
|
else: "smtp.example.com"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<.input
|
||||||
|
field={@form[:smtp_port]}
|
||||||
|
type="number"
|
||||||
|
label={gettext("Port")}
|
||||||
|
disabled={@smtp_port_env_set}
|
||||||
|
placeholder={if(@smtp_port_env_set, do: gettext("From SMTP_PORT"), else: "587")}
|
||||||
|
/>
|
||||||
|
<.input
|
||||||
|
field={@form[:smtp_ssl]}
|
||||||
|
type="select"
|
||||||
|
label={gettext("TLS/SSL")}
|
||||||
|
disabled={@smtp_ssl_env_set}
|
||||||
|
options={[
|
||||||
|
{gettext("TLS (port 587, recommended)"), "tls"},
|
||||||
|
{gettext("SSL (port 465)"), "ssl"},
|
||||||
|
{gettext("None (port 25, insecure)"), "none"}
|
||||||
|
]}
|
||||||
|
placeholder={if(@smtp_ssl_env_set, do: gettext("From SMTP_SSL"), else: nil)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
<.input
|
||||||
|
field={@form[:smtp_username]}
|
||||||
|
type="text"
|
||||||
|
label={gettext("Username")}
|
||||||
|
disabled={@smtp_username_env_set}
|
||||||
|
placeholder={
|
||||||
|
if(@smtp_username_env_set,
|
||||||
|
do: gettext("From SMTP_USERNAME"),
|
||||||
|
else: "user@example.com"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<.input
|
||||||
|
field={@form[:smtp_password]}
|
||||||
|
type="password"
|
||||||
|
label={gettext("Password")}
|
||||||
|
disabled={@smtp_password_env_set}
|
||||||
|
placeholder={
|
||||||
|
if(@smtp_password_env_set,
|
||||||
|
do: gettext("From SMTP_PASSWORD"),
|
||||||
|
else:
|
||||||
|
if(@smtp_password_set,
|
||||||
|
do: gettext("Leave blank to keep current"),
|
||||||
|
else: nil
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
<.input
|
||||||
|
field={@form[:smtp_from_email]}
|
||||||
|
type="email"
|
||||||
|
label={gettext("Sender email (From)")}
|
||||||
|
disabled={@smtp_from_email_env_set}
|
||||||
|
placeholder={
|
||||||
|
if(@smtp_from_email_env_set,
|
||||||
|
do: gettext("From MAIL_FROM_EMAIL"),
|
||||||
|
else: "noreply@example.com"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<.input
|
||||||
|
field={@form[:smtp_from_name]}
|
||||||
|
type="text"
|
||||||
|
label={gettext("Sender name (From)")}
|
||||||
|
disabled={@smtp_from_name_env_set}
|
||||||
|
placeholder={
|
||||||
|
if(@smtp_from_name_env_set, do: gettext("From MAIL_FROM_NAME"), else: "Mila")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mb-3 text-sm text-base-content/60">
|
||||||
|
{gettext(
|
||||||
|
"The sender email must be owned by or authorized for the SMTP user on most servers."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<.button
|
||||||
|
:if={
|
||||||
|
not (@smtp_host_env_set and @smtp_port_env_set and @smtp_username_env_set and
|
||||||
|
@smtp_password_env_set and @smtp_ssl_env_set and @smtp_from_email_env_set and
|
||||||
|
@smtp_from_name_env_set)
|
||||||
|
}
|
||||||
|
phx-disable-with={gettext("Saving...")}
|
||||||
|
variant="primary"
|
||||||
|
class="mt-2"
|
||||||
|
>
|
||||||
|
{gettext("Save SMTP Settings")}
|
||||||
|
</.button>
|
||||||
|
</.form>
|
||||||
|
|
||||||
|
<%!-- Test email: use form phx-submit so the current input value is always sent (e.g. after paste without blur) --%>
|
||||||
|
<div class="mt-6">
|
||||||
|
<h3 class="font-medium mb-3">{gettext("Test email")}</h3>
|
||||||
|
<.form
|
||||||
|
for={%{}}
|
||||||
|
id="smtp-test-email-form"
|
||||||
|
data-testid="smtp-test-email-form"
|
||||||
|
phx-submit="send_smtp_test_email"
|
||||||
|
class="space-y-3"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-end gap-3">
|
||||||
|
<fieldset class="fieldset">
|
||||||
|
<label>
|
||||||
|
<span class="mb-1 label">{gettext("Recipient")}</span>
|
||||||
|
<input
|
||||||
|
id="smtp-test-to-email"
|
||||||
|
type="email"
|
||||||
|
name="to_email"
|
||||||
|
data-testid="smtp-test-email-input"
|
||||||
|
value={@smtp_test_to_email}
|
||||||
|
class="w-full input input-bordered"
|
||||||
|
placeholder="test@example.com"
|
||||||
|
phx-change="update_smtp_test_to_email"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
<.button
|
||||||
|
type="submit"
|
||||||
|
variant="secondary"
|
||||||
|
class="mb-1"
|
||||||
|
data-testid="smtp-send-test-email"
|
||||||
|
phx-disable-with={gettext("Sending...")}
|
||||||
|
>
|
||||||
|
{gettext("Send test email")}
|
||||||
|
</.button>
|
||||||
|
</div>
|
||||||
|
</.form>
|
||||||
|
<%= if @smtp_test_result do %>
|
||||||
|
<div data-testid="smtp-test-result">
|
||||||
|
<.smtp_test_result result={@smtp_test_result} />
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</.form_section>
|
||||||
|
|
||||||
<%!-- Vereinfacht Integration Section --%>
|
<%!-- Vereinfacht Integration Section --%>
|
||||||
<.form_section title={gettext("Accounting-Software (Vereinfacht) Integration")}>
|
<.form_section title={gettext("Accounting-Software (Vereinfacht) Integration")}>
|
||||||
<%= if @vereinfacht_env_configured do %>
|
<%= if @vereinfacht_env_configured do %>
|
||||||
|
|
@ -290,19 +504,27 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div class="form-control">
|
<fieldset class="mb-2 fieldset">
|
||||||
<label class="label" for={@form[:vereinfacht_api_key].id}>
|
<label>
|
||||||
<span class="label-text">{gettext("API Key")}</span>
|
<span class="mb-1 label">{gettext("API Key")}</span>
|
||||||
<%= if @vereinfacht_api_key_set do %>
|
<%= if @vereinfacht_api_key_set do %>
|
||||||
<span class="label-text-alt">
|
<span class="label-text-alt">
|
||||||
<.badge variant="neutral" size="sm">{gettext("(set)")}</.badge>
|
<.badge variant="neutral" size="sm">{gettext("(set)")}</.badge>
|
||||||
</span>
|
</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</label>
|
<input
|
||||||
<.input
|
|
||||||
field={@form[:vereinfacht_api_key]}
|
|
||||||
type="password"
|
type="password"
|
||||||
label=""
|
name={@form[:vereinfacht_api_key].name}
|
||||||
|
id={@form[:vereinfacht_api_key].id}
|
||||||
|
value={
|
||||||
|
Phoenix.HTML.Form.normalize_value("password", @form[:vereinfacht_api_key].value)
|
||||||
|
}
|
||||||
|
class={
|
||||||
|
if Phoenix.Component.used_input?(@form[:vereinfacht_api_key]) &&
|
||||||
|
@form[:vereinfacht_api_key].errors != [],
|
||||||
|
do: "w-full input input-error",
|
||||||
|
else: "w-full input"
|
||||||
|
}
|
||||||
disabled={@vereinfacht_api_key_env_set}
|
disabled={@vereinfacht_api_key_env_set}
|
||||||
placeholder={
|
placeholder={
|
||||||
if(@vereinfacht_api_key_env_set,
|
if(@vereinfacht_api_key_env_set,
|
||||||
|
|
@ -315,7 +537,20 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</label>
|
||||||
|
<%= for msg <- (
|
||||||
|
if Phoenix.Component.used_input?(@form[:vereinfacht_api_key]) do
|
||||||
|
Enum.map(@form[:vereinfacht_api_key].errors, &MvWeb.CoreComponents.translate_error/1)
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
) do %>
|
||||||
|
<p class="mt-1.5 flex gap-2 items-center text-sm text-error">
|
||||||
|
<.icon name="hero-exclamation-circle" class="size-5" />
|
||||||
|
{msg}
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
</fieldset>
|
||||||
<.input
|
<.input
|
||||||
field={@form[:vereinfacht_club_id]}
|
field={@form[:vereinfacht_club_id]}
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -353,7 +588,7 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
<.button
|
<.button
|
||||||
:if={Mv.Config.vereinfacht_configured?()}
|
:if={Mv.Config.vereinfacht_configured?()}
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="secondary"
|
||||||
phx-click="test_vereinfacht_connection"
|
phx-click="test_vereinfacht_connection"
|
||||||
phx-disable-with={gettext("Testing...")}
|
phx-disable-with={gettext("Testing...")}
|
||||||
>
|
>
|
||||||
|
|
@ -362,7 +597,7 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
<.button
|
<.button
|
||||||
:if={Mv.Config.vereinfacht_configured?()}
|
:if={Mv.Config.vereinfacht_configured?()}
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="secondary"
|
||||||
phx-click="sync_vereinfacht_contacts"
|
phx-click="sync_vereinfacht_contacts"
|
||||||
phx-disable-with={gettext("Syncing...")}
|
phx-disable-with={gettext("Syncing...")}
|
||||||
>
|
>
|
||||||
|
|
@ -377,13 +612,85 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
<% end %>
|
<% end %>
|
||||||
</.form>
|
</.form>
|
||||||
</.form_section>
|
</.form_section>
|
||||||
<%!-- OIDC Section --%>
|
<%!-- Authentication: Direct registration + OIDC --%>
|
||||||
<.form_section title={gettext("OIDC (Single Sign-On)")}>
|
<.form_section title={gettext("Authentication")}>
|
||||||
|
<h3 class="font-medium mb-3">{gettext("Direct registration")}</h3>
|
||||||
|
<p class="text-sm text-base-content/70 mb-4">
|
||||||
|
{gettext(
|
||||||
|
"If disabled, users cannot sign up via /register; sign-in and the join form remain available."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center gap-3 mb-6">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="registration-enabled-checkbox"
|
||||||
|
class="checkbox checkbox-sm"
|
||||||
|
checked={@registration_enabled}
|
||||||
|
phx-click="toggle_registration_enabled"
|
||||||
|
disabled={@oidc_only}
|
||||||
|
aria-label={gettext("Allow direct registration (/register)")}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
for="registration-enabled-checkbox"
|
||||||
|
class={
|
||||||
|
if @oidc_only, do: "cursor-not-allowed opacity-70", else: "cursor-pointer font-medium"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{gettext("Allow direct registration (/register)")}
|
||||||
|
</label>
|
||||||
|
<%= if @oidc_only do %>
|
||||||
|
<.tooltip
|
||||||
|
content={gettext("Only OIDC sign-in is active. This option is disabled.")}
|
||||||
|
position="top"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
data-testid="oidc-only-registration-hint"
|
||||||
|
class="cursor-help text-base-content/70"
|
||||||
|
>
|
||||||
|
ⓘ
|
||||||
|
</span>
|
||||||
|
</.tooltip>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="font-medium mb-3">{gettext("OIDC (Single Sign-On)")}</h3>
|
||||||
<%= if @oidc_env_configured do %>
|
<%= if @oidc_env_configured do %>
|
||||||
<p class="text-sm text-base-content/70 mb-4">
|
<p class="text-sm text-base-content/70 mb-4">
|
||||||
{gettext("Some values are set via environment variables. Those fields are read-only.")}
|
{gettext("Some values are set via environment variables. Those fields are read-only.")}
|
||||||
</p>
|
</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<div class="flex items-center gap-3 mb-4">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="oidc-only-checkbox"
|
||||||
|
data-testid="oidc-only-checkbox"
|
||||||
|
class="checkbox checkbox-sm"
|
||||||
|
checked={@oidc_only}
|
||||||
|
phx-click="toggle_oidc_only"
|
||||||
|
disabled={@oidc_only_env_set or not @oidc_configured}
|
||||||
|
aria-label={gettext("Only OIDC sign-in (hide password login)")}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
for="oidc-only-checkbox"
|
||||||
|
class={
|
||||||
|
if @oidc_only_env_set or not @oidc_configured,
|
||||||
|
do: "cursor-not-allowed opacity-70",
|
||||||
|
else: "cursor-pointer font-medium"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{if @oidc_only_env_set do
|
||||||
|
gettext("Only OIDC sign-in (hide password login)") <>
|
||||||
|
" (" <> gettext("From OIDC_ONLY") <> ")"
|
||||||
|
else
|
||||||
|
gettext("Only OIDC sign-in (hide password login)")
|
||||||
|
end}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="label-text-alt text-base-content/70 mb-4">
|
||||||
|
{gettext(
|
||||||
|
"When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
<.form for={@form} id="oidc-form" phx-change="validate" phx-submit="save">
|
<.form for={@form} id="oidc-form" phx-change="validate" phx-submit="save">
|
||||||
<div class="grid gap-4">
|
<div class="grid gap-4">
|
||||||
<.input
|
<.input
|
||||||
|
|
@ -419,19 +726,27 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div class="form-control">
|
<fieldset class="mb-2 fieldset">
|
||||||
<label class="label" for={@form[:oidc_client_secret].id}>
|
<label>
|
||||||
<span class="label-text">{gettext("Client Secret")}</span>
|
<span class="mb-1 label">{gettext("Client Secret")}</span>
|
||||||
<%= if @oidc_client_secret_set do %>
|
<%= if @oidc_client_secret_set do %>
|
||||||
<span class="label-text-alt">
|
<span class="label-text-alt">
|
||||||
<.badge variant="neutral" size="sm">{gettext("(set)")}</.badge>
|
<.badge variant="neutral" size="sm">{gettext("(set)")}</.badge>
|
||||||
</span>
|
</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</label>
|
<input
|
||||||
<.input
|
|
||||||
field={@form[:oidc_client_secret]}
|
|
||||||
type="password"
|
type="password"
|
||||||
label=""
|
name={@form[:oidc_client_secret].name}
|
||||||
|
id={@form[:oidc_client_secret].id}
|
||||||
|
value={
|
||||||
|
Phoenix.HTML.Form.normalize_value("password", @form[:oidc_client_secret].value)
|
||||||
|
}
|
||||||
|
class={
|
||||||
|
if Phoenix.Component.used_input?(@form[:oidc_client_secret]) &&
|
||||||
|
@form[:oidc_client_secret].errors != [],
|
||||||
|
do: "w-full input input-error",
|
||||||
|
else: "w-full input"
|
||||||
|
}
|
||||||
disabled={@oidc_client_secret_env_set}
|
disabled={@oidc_client_secret_env_set}
|
||||||
placeholder={
|
placeholder={
|
||||||
if(@oidc_client_secret_env_set,
|
if(@oidc_client_secret_env_set,
|
||||||
|
|
@ -444,7 +759,20 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</label>
|
||||||
|
<%= for msg <- (
|
||||||
|
if Phoenix.Component.used_input?(@form[:oidc_client_secret]) do
|
||||||
|
Enum.map(@form[:oidc_client_secret].errors, &MvWeb.CoreComponents.translate_error/1)
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
) do %>
|
||||||
|
<p class="mt-1.5 flex gap-2 items-center text-sm text-error">
|
||||||
|
<.icon name="hero-exclamation-circle" class="size-5" />
|
||||||
|
{msg}
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
</fieldset>
|
||||||
<.input
|
<.input
|
||||||
field={@form[:oidc_admin_group_name]}
|
field={@form[:oidc_admin_group_name]}
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -469,27 +797,6 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div class="form-control">
|
|
||||||
<.input
|
|
||||||
field={@form[:oidc_only]}
|
|
||||||
type="checkbox"
|
|
||||||
class="checkbox checkbox-sm"
|
|
||||||
disabled={@oidc_only_env_set or not @oidc_configured}
|
|
||||||
label={
|
|
||||||
if @oidc_only_env_set do
|
|
||||||
gettext("Only OIDC sign-in (hide password login)") <>
|
|
||||||
" (" <> gettext("From OIDC_ONLY") <> ")"
|
|
||||||
else
|
|
||||||
gettext("Only OIDC sign-in (hide password login)")
|
|
||||||
end
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<p class="label-text-alt text-base-content/70 mt-1">
|
|
||||||
{gettext(
|
|
||||||
"When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button."
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<.button
|
<.button
|
||||||
:if={
|
:if={
|
||||||
|
|
@ -506,6 +813,7 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
</.button>
|
</.button>
|
||||||
</.form>
|
</.form>
|
||||||
</.form_section>
|
</.form_section>
|
||||||
|
</div>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
@ -516,6 +824,27 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, setting_params))}
|
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, setting_params))}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# phx-change can fire without "setting" (e.g. only _target when focusing). Do not validate
|
||||||
|
# with previous form params to avoid surprising behaviour; wait for the next event with setting data.
|
||||||
|
def handle_event("validate", _params, socket) do
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("update_smtp_test_to_email", %{"to_email" => email}, socket) do
|
||||||
|
{:noreply, assign(socket, :smtp_test_to_email, email)}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("send_smtp_test_email", params, socket) do
|
||||||
|
to_email =
|
||||||
|
(params["to_email"] || socket.assigns.smtp_test_to_email || "")
|
||||||
|
|> String.trim()
|
||||||
|
|
||||||
|
result = Mv.Mailer.send_test_email(to_email)
|
||||||
|
{:noreply, assign(socket, :smtp_test_result, result)}
|
||||||
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("test_vereinfacht_connection", _params, socket) do
|
def handle_event("test_vereinfacht_connection", _params, socket) do
|
||||||
result = Mv.Vereinfacht.test_connection()
|
result = Mv.Vereinfacht.test_connection()
|
||||||
|
|
@ -560,27 +889,35 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("save", %{"setting" => setting_params}, socket) do
|
def handle_event("save", %{"setting" => setting_params}, socket) do
|
||||||
actor = MvWeb.LiveHelpers.current_actor(socket)
|
actor = MvWeb.LiveHelpers.current_actor(socket)
|
||||||
# Never send blank API key / client secret so we do not overwrite stored secrets
|
|
||||||
|
# Never send blank API key / client secret / smtp password so we do not overwrite stored secrets
|
||||||
setting_params_clean =
|
setting_params_clean =
|
||||||
setting_params
|
setting_params
|
||||||
|> drop_blank_vereinfacht_api_key()
|
|> drop_blank_vereinfacht_api_key()
|
||||||
|> drop_blank_oidc_client_secret()
|
|> drop_blank_oidc_client_secret()
|
||||||
|
|> drop_blank_smtp_password()
|
||||||
|
|
||||||
saves_vereinfacht = vereinfacht_params?(setting_params_clean)
|
saves_vereinfacht = vereinfacht_params?(setting_params_clean)
|
||||||
|
|
||||||
case MvWeb.LiveHelpers.submit_form(socket.assigns.form, setting_params_clean, actor) do
|
case MvWeb.LiveHelpers.submit_form(socket.assigns.form, setting_params_clean, actor) do
|
||||||
{:ok, _updated_settings} ->
|
{:ok, updated_settings} ->
|
||||||
{:ok, fresh_settings} = Membership.get_settings()
|
# Use the returned record for the form so saved values show immediately;
|
||||||
|
# get_settings() can return cached data without the new attribute until reload.
|
||||||
test_result =
|
test_result =
|
||||||
if saves_vereinfacht, do: Mv.Vereinfacht.test_connection(), else: nil
|
if saves_vereinfacht, do: Mv.Vereinfacht.test_connection(), else: nil
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(:settings, fresh_settings)
|
|> assign(:settings, updated_settings)
|
||||||
|> assign(:vereinfacht_api_key_set, present?(fresh_settings.vereinfacht_api_key))
|
|> assign(:registration_enabled, updated_settings.registration_enabled != false)
|
||||||
|> assign(:oidc_client_secret_set, present?(fresh_settings.oidc_client_secret))
|
|> assign(:vereinfacht_api_key_set, present?(updated_settings.vereinfacht_api_key))
|
||||||
|
|> assign(:oidc_client_secret_set, Mv.Config.oidc_client_secret_set?())
|
||||||
|
|> assign(:oidc_only, Mv.Config.oidc_only?())
|
||||||
|> assign(:oidc_configured, Mv.Config.oidc_configured?())
|
|> assign(:oidc_configured, Mv.Config.oidc_configured?())
|
||||||
|
|> assign(:smtp_configured, Mv.Config.smtp_configured?())
|
||||||
|
|> assign(:smtp_password_set, present?(Mv.Config.smtp_password()))
|
||||||
|
|> assign(:smtp_from_name_env_set, Mv.Config.mail_from_name_env_set?())
|
||||||
|
|> assign(:smtp_from_email_env_set, Mv.Config.mail_from_email_env_set?())
|
||||||
|> assign(:vereinfacht_test_result, test_result)
|
|> assign(:vereinfacht_test_result, test_result)
|
||||||
|> put_flash(:success, gettext("Settings updated successfully"))
|
|> put_flash(:success, gettext("Settings updated successfully"))
|
||||||
|> assign_form()
|
|> assign_form()
|
||||||
|
|
@ -594,12 +931,74 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
|
|
||||||
# ---- Join form event handlers ----
|
# ---- Join form event handlers ----
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("copy_join_url", _params, socket) do
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> push_event("copy_to_clipboard", %{text: socket.assigns.join_url})
|
||||||
|
|> put_flash(:success, gettext("Join page URL copied to clipboard."))
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("toggle_join_form_enabled", _params, socket) do
|
def handle_event("toggle_join_form_enabled", _params, socket) do
|
||||||
socket = assign(socket, :join_form_enabled, not socket.assigns.join_form_enabled)
|
socket = assign(socket, :join_form_enabled, not socket.assigns.join_form_enabled)
|
||||||
{:noreply, persist_join_form_settings(socket)}
|
{:noreply, persist_join_form_settings(socket)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("toggle_registration_enabled", _params, socket) do
|
||||||
|
if Mv.Config.oidc_only?() do
|
||||||
|
{:noreply, socket}
|
||||||
|
else
|
||||||
|
settings = socket.assigns.settings
|
||||||
|
new_value = not socket.assigns.registration_enabled
|
||||||
|
|
||||||
|
case Membership.update_settings(settings, %{registration_enabled: new_value}) do
|
||||||
|
{:ok, updated_settings} ->
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:settings, updated_settings)
|
||||||
|
|> assign(:registration_enabled, updated_settings.registration_enabled != false)
|
||||||
|
|> assign_form()}
|
||||||
|
|
||||||
|
{:error, _} ->
|
||||||
|
{:noreply, put_flash(socket, :error, gettext("Failed to update setting."))}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("toggle_oidc_only", _params, socket) do
|
||||||
|
if socket.assigns.oidc_only_env_set do
|
||||||
|
{:noreply, socket}
|
||||||
|
else
|
||||||
|
settings = socket.assigns.settings
|
||||||
|
new_value = not socket.assigns.oidc_only
|
||||||
|
|
||||||
|
# When enabling OIDC-only, also disable direct registration; when disabling, only change oidc_only.
|
||||||
|
params =
|
||||||
|
if new_value,
|
||||||
|
do: %{oidc_only: true, registration_enabled: false},
|
||||||
|
else: %{oidc_only: false}
|
||||||
|
|
||||||
|
case Membership.update_settings(settings, params) do
|
||||||
|
{:ok, updated_settings} ->
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:settings, updated_settings)
|
||||||
|
|> assign(:oidc_only, updated_settings.oidc_only == true)
|
||||||
|
|> assign(:registration_enabled, updated_settings.registration_enabled != false)
|
||||||
|
|> assign_form()
|
||||||
|
|> put_flash(:success, gettext("Settings updated successfully"))}
|
||||||
|
|
||||||
|
{:error, _} ->
|
||||||
|
{:noreply, put_flash(socket, :error, gettext("Failed to update setting."))}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("toggle_add_field_dropdown", _params, socket) do
|
def handle_event("toggle_add_field_dropdown", _params, socket) do
|
||||||
{:noreply,
|
{:noreply,
|
||||||
|
|
@ -760,17 +1159,29 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp drop_blank_smtp_password(params) when is_map(params) do
|
||||||
|
case params do
|
||||||
|
%{"smtp_password" => v} when v in [nil, ""] ->
|
||||||
|
Map.delete(params, "smtp_password")
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
params
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp assign_form(%{assigns: %{settings: settings}} = socket) do
|
defp assign_form(%{assigns: %{settings: settings}} = socket) do
|
||||||
# Show ENV values in disabled fields (Vereinfacht and OIDC); never expose API key / client secret
|
# Show ENV values in disabled fields (Vereinfacht, OIDC, SMTP); never expose secrets in form
|
||||||
settings_display =
|
settings_display =
|
||||||
settings
|
settings
|
||||||
|> merge_vereinfacht_env_values()
|
|> merge_vereinfacht_env_values()
|
||||||
|> merge_oidc_env_values()
|
|> merge_oidc_env_values()
|
||||||
|
|> merge_smtp_env_values()
|
||||||
|
|
||||||
settings_for_form = %{
|
settings_for_form = %{
|
||||||
settings_display
|
settings_display
|
||||||
| vereinfacht_api_key: nil,
|
| vereinfacht_api_key: nil,
|
||||||
oidc_client_secret: nil
|
oidc_client_secret: nil,
|
||||||
|
smtp_password: nil
|
||||||
}
|
}
|
||||||
|
|
||||||
form =
|
form =
|
||||||
|
|
@ -845,6 +1256,28 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp merge_smtp_env_values(s) do
|
||||||
|
s
|
||||||
|
|> put_if_env_set(:smtp_host, Mv.Config.smtp_host_env_set?(), Mv.Config.smtp_host())
|
||||||
|
|> put_if_env_set(:smtp_port, Mv.Config.smtp_port_env_set?(), Mv.Config.smtp_port())
|
||||||
|
|> put_if_env_set(
|
||||||
|
:smtp_username,
|
||||||
|
Mv.Config.smtp_username_env_set?(),
|
||||||
|
Mv.Config.smtp_username()
|
||||||
|
)
|
||||||
|
|> put_if_env_set(:smtp_ssl, Mv.Config.smtp_ssl_env_set?(), Mv.Config.smtp_ssl())
|
||||||
|
|> put_if_env_set(
|
||||||
|
:smtp_from_email,
|
||||||
|
Mv.Config.mail_from_email_env_set?(),
|
||||||
|
Mv.Config.mail_from_email()
|
||||||
|
)
|
||||||
|
|> put_if_env_set(
|
||||||
|
:smtp_from_name,
|
||||||
|
Mv.Config.mail_from_name_env_set?(),
|
||||||
|
Mv.Config.mail_from_name()
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
defp enrich_sync_errors([]), do: []
|
defp enrich_sync_errors([]), do: []
|
||||||
|
|
||||||
defp enrich_sync_errors(errors) when is_list(errors) do
|
defp enrich_sync_errors(errors) when is_list(errors) do
|
||||||
|
|
@ -1018,6 +1451,115 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# ---- SMTP test result component ----
|
||||||
|
|
||||||
|
attr :result, :any, required: true
|
||||||
|
|
||||||
|
defp smtp_test_result(%{result: {:ok, _}} = assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-success bg-success/10 text-success-aa text-sm">
|
||||||
|
<.icon name="hero-check-circle" class="size-5 shrink-0" />
|
||||||
|
<span>{gettext("Test email sent successfully.")}</span>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp smtp_test_result(%{result: {:error, :invalid_email_address}} = assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
|
||||||
|
<.icon name="hero-x-circle" class="size-5 shrink-0" />
|
||||||
|
<span>{gettext("Invalid email address. Please enter a valid recipient address.")}</span>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp smtp_test_result(%{result: {:error, :not_implemented}} = assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-warning bg-warning/10 text-warning-aa text-sm">
|
||||||
|
<.icon name="hero-exclamation-triangle" class="size-5 shrink-0" />
|
||||||
|
<span>{gettext("SMTP is not configured. Please set at least the SMTP host.")}</span>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp smtp_test_result(%{result: {:error, :sender_rejected}} = assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
|
||||||
|
<.icon name="hero-x-circle" class="size-5 shrink-0" />
|
||||||
|
<span>
|
||||||
|
{gettext(
|
||||||
|
"Sender address rejected. The \"Sender email\" must be owned by or authorized for the SMTP user."
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp smtp_test_result(%{result: {:error, :auth_failed}} = assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
|
||||||
|
<.icon name="hero-x-circle" class="size-5 shrink-0" />
|
||||||
|
<span>
|
||||||
|
{gettext("Authentication failed. Please check the SMTP username and password.")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp smtp_test_result(%{result: {:error, :recipient_rejected}} = assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
|
||||||
|
<.icon name="hero-x-circle" class="size-5 shrink-0" />
|
||||||
|
<span>{gettext("Recipient address rejected by the server.")}</span>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp smtp_test_result(%{result: {:error, :tls_failed}} = assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
|
||||||
|
<.icon name="hero-x-circle" class="size-5 shrink-0" />
|
||||||
|
<span>
|
||||||
|
{gettext(
|
||||||
|
"TLS connection failed. Check the TLS/SSL setting and port (587 for TLS, 465 for SSL)."
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp smtp_test_result(%{result: {:error, :connection_failed}} = assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
|
||||||
|
<.icon name="hero-x-circle" class="size-5 shrink-0" />
|
||||||
|
<span>
|
||||||
|
{gettext("Server unreachable. Check host and port.")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp smtp_test_result(%{result: {:error, {:smtp_error, message}}} = assigns)
|
||||||
|
when is_binary(message) do
|
||||||
|
~H"""
|
||||||
|
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
|
||||||
|
<.icon name="hero-x-circle" class="size-5 shrink-0" />
|
||||||
|
<span>
|
||||||
|
{gettext("SMTP error:")} {@result |> elem(1) |> elem(1)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp smtp_test_result(%{result: {:error, _reason}} = assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
|
||||||
|
<.icon name="hero-x-circle" class="size-5 shrink-0" />
|
||||||
|
<span>{gettext("Failed to send test email. Please check your SMTP configuration.")}</span>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
# ---- Join form helper functions ----
|
# ---- Join form helper functions ----
|
||||||
|
|
||||||
defp assign_join_form_state(socket, settings, custom_fields) do
|
defp assign_join_form_state(socket, settings, custom_fields) do
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ defmodule MvWeb.GroupLive.Form do
|
||||||
socket
|
socket
|
||||||
|> assign(:actor, actor)
|
|> assign(:actor, actor)
|
||||||
|> assign(:group, nil)
|
|> assign(:group, nil)
|
||||||
|> assign(:page_title, page_title_for_params(params))
|
|> Layouts.assign_page_title(page_title_for_params(params))
|
||||||
|> assign(:return_to, return_to_for_params(params))}
|
|> assign(:return_to, return_to_for_params(params))}
|
||||||
else
|
else
|
||||||
{:ok, redirect(socket, to: ~p"/groups")}
|
{:ok, redirect(socket, to: ~p"/groups")}
|
||||||
|
|
@ -56,7 +56,7 @@ defmodule MvWeb.GroupLive.Form do
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> assign(:group, group)
|
|> assign(:group, group)
|
||||||
|> assign(:page_title, gettext("Edit Group"))
|
|> Layouts.assign_page_title(gettext("Edit Group"))
|
||||||
|> assign(:return_to, :show)
|
|> assign(:return_to, :show)
|
||||||
|> assign_form(actor)}
|
|> assign_form(actor)}
|
||||||
|
|
||||||
|
|
@ -85,7 +85,7 @@ defmodule MvWeb.GroupLive.Form do
|
||||||
{gettext("Back")}
|
{gettext("Back")}
|
||||||
</.button>
|
</.button>
|
||||||
</:leading>
|
</:leading>
|
||||||
{@page_title}
|
{@content_title}
|
||||||
<:actions>
|
<:actions>
|
||||||
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
|
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
|
||||||
{gettext("Save")}
|
{gettext("Save")}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ defmodule MvWeb.GroupLive.Index do
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, gettext("Groups"))
|
|> Layouts.assign_page_title(gettext("Groups"))
|
||||||
|> assign(:groups, groups)}
|
|> assign(:groups, groups)}
|
||||||
else
|
else
|
||||||
{:ok, redirect(socket, to: ~p"/members")}
|
{:ok, redirect(socket, to: ~p"/members")}
|
||||||
|
|
@ -40,7 +40,7 @@ defmodule MvWeb.GroupLive.Index do
|
||||||
~H"""
|
~H"""
|
||||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||||
<.header>
|
<.header>
|
||||||
{gettext("Groups")}
|
{@content_title}
|
||||||
<:actions>
|
<:actions>
|
||||||
<%= if can?(@current_user, :create, Mv.Membership.Group) do %>
|
<%= if can?(@current_user, :create, Mv.Membership.Group) do %>
|
||||||
<.button navigate={~p"/groups/new"} variant="primary">
|
<.button navigate={~p"/groups/new"} variant="primary">
|
||||||
|
|
|
||||||
|
|
@ -70,9 +70,11 @@ defmodule MvWeb.GroupLive.Show do
|
||||||
{:ok, group} ->
|
{:ok, group} ->
|
||||||
open_delete = params["confirm_delete"] == "1" && can?(actor, :destroy, group)
|
open_delete = params["confirm_delete"] == "1" && can?(actor, :destroy, group)
|
||||||
|
|
||||||
|
content_title = gettext("Group %{name}", name: group.name)
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, group.name)
|
|> Layouts.assign_page_title(content_title)
|
||||||
|> assign(:group, group)
|
|> assign(:group, group)
|
||||||
|> assign(:show_delete_modal, open_delete)
|
|> assign(:show_delete_modal, open_delete)
|
||||||
|> assign(:name_confirmation, "")
|
|> assign(:name_confirmation, "")
|
||||||
|
|
@ -102,7 +104,7 @@ defmodule MvWeb.GroupLive.Show do
|
||||||
{gettext("Back")}
|
{gettext("Back")}
|
||||||
</.button>
|
</.button>
|
||||||
</:leading>
|
</:leading>
|
||||||
{@group.name}
|
{@content_title}
|
||||||
<:actions>
|
<:actions>
|
||||||
<%= if can?(@current_user, :update, @group) do %>
|
<%= if can?(@current_user, :update, @group) do %>
|
||||||
<.button
|
<.button
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ defmodule MvWeb.ImportLive do
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, gettext("Import"))
|
|> Layouts.assign_page_title(gettext("Import"))
|
||||||
|> assign(:club_name, club_name)
|
|> assign(:club_name, club_name)
|
||||||
|> assign(:import_state, nil)
|
|> assign(:import_state, nil)
|
||||||
|> assign(:import_progress, nil)
|
|> assign(:import_progress, nil)
|
||||||
|
|
@ -94,7 +94,7 @@ defmodule MvWeb.ImportLive do
|
||||||
<%!-- CSV Import Section --%>
|
<%!-- CSV Import Section --%>
|
||||||
<div data-testid="import-page">
|
<div data-testid="import-page">
|
||||||
<.header>
|
<.header>
|
||||||
{gettext("Import Members")}
|
{@content_title}
|
||||||
<:subtitle>
|
<:subtitle>
|
||||||
{gettext("Import members from CSV files.")}
|
{gettext("Import members from CSV files.")}
|
||||||
</:subtitle>
|
</:subtitle>
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,22 @@ defmodule MvWeb.JoinLive do
|
||||||
# Honeypot field name (legitimate-sounding to avoid bot detection)
|
# Honeypot field name (legitimate-sounding to avoid bot detection)
|
||||||
@honeypot_field "website"
|
@honeypot_field "website"
|
||||||
|
|
||||||
|
# Anti-enumeration: delay before showing success (ms). Applied in LiveView so the process is not blocked.
|
||||||
|
@anti_enumeration_delay_ms_min 100
|
||||||
|
@anti_enumeration_delay_ms_rand 200
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
allowlist = Membership.get_join_form_allowlist()
|
allowlist = Membership.get_join_form_allowlist()
|
||||||
join_fields = build_join_fields_with_labels(allowlist)
|
join_fields = build_join_fields_with_labels(allowlist)
|
||||||
client_ip = client_ip_from_socket(socket)
|
client_ip = client_ip_from_socket(socket)
|
||||||
|
|
||||||
|
club_name =
|
||||||
|
case Membership.get_settings() do
|
||||||
|
{:ok, s} -> s.club_name || "Mitgliederverwaltung"
|
||||||
|
_ -> "Mitgliederverwaltung"
|
||||||
|
end
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(:join_fields, join_fields)
|
|> assign(:join_fields, join_fields)
|
||||||
|
|
@ -25,6 +35,8 @@ defmodule MvWeb.JoinLive do
|
||||||
|> assign(:rate_limit_error, nil)
|
|> assign(:rate_limit_error, nil)
|
||||||
|> assign(:client_ip, client_ip)
|
|> assign(:client_ip, client_ip)
|
||||||
|> assign(:honeypot_field, @honeypot_field)
|
|> assign(:honeypot_field, @honeypot_field)
|
||||||
|
|> assign(:club_name, club_name)
|
||||||
|
|> Layouts.assign_page_title(gettext("Join"))
|
||||||
|> assign(:form, to_form(initial_form_params(join_fields)))
|
|> assign(:form, to_form(initial_form_params(join_fields)))
|
||||||
|
|
||||||
{:ok, socket}
|
{:ok, socket}
|
||||||
|
|
@ -33,8 +45,11 @@ defmodule MvWeb.JoinLive do
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
<Layouts.public_page flash={@flash} club_name={@club_name}>
|
||||||
<div class="max-w-xl mx-auto mt-8 space-y-6">
|
<div class="max-w-4xl mx-auto">
|
||||||
|
<div class="hero min-h-[60vh] bg-base-200 rounded-lg">
|
||||||
|
<div class="hero-content flex-col items-start text-left">
|
||||||
|
<div class="max-w-xl w-full space-y-6">
|
||||||
<.header>
|
<.header>
|
||||||
{gettext("Become a member")}
|
{gettext("Become a member")}
|
||||||
</.header>
|
</.header>
|
||||||
|
|
@ -97,13 +112,13 @@ defmodule MvWeb.JoinLive do
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-sm text-base-content/70">
|
<p class="text-sm text-base-content/85">
|
||||||
{gettext(
|
{gettext(
|
||||||
"By submitting your application you will receive an email with a confirmation link. Once you have confirmed your email address, your application will be reviewed."
|
"By submitting your application you will receive an email with a confirmation link. Once you have confirmed your email address, your application will be reviewed."
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p class="text-xs text-base-content/60">
|
<p class="text-xs text-base-content/80">
|
||||||
{gettext(
|
{gettext(
|
||||||
"Your details are only used to process your membership application and to contact you. To prevent abuse we also process technical data (e.g. IP address) only as necessary."
|
"Your details are only used to process your membership application and to contact you. To prevent abuse we also process technical data (e.g. IP address) only as necessary."
|
||||||
)}
|
)}
|
||||||
|
|
@ -117,7 +132,10 @@ defmodule MvWeb.JoinLive do
|
||||||
</.form>
|
</.form>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</Layouts.app>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layouts.public_page>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -142,8 +160,26 @@ defmodule MvWeb.JoinLive do
|
||||||
case build_submit_attrs(params, socket.assigns.join_fields) do
|
case build_submit_attrs(params, socket.assigns.join_fields) do
|
||||||
{:ok, attrs} ->
|
{:ok, attrs} ->
|
||||||
case Membership.submit_join_request(attrs, actor: nil) do
|
case Membership.submit_join_request(attrs, actor: nil) do
|
||||||
{:ok, _} -> {:noreply, assign(socket, :submitted, true)}
|
{:ok, _} ->
|
||||||
{:error, _} -> validation_error_reply(socket, params)
|
delay_ms =
|
||||||
|
@anti_enumeration_delay_ms_min + :rand.uniform(@anti_enumeration_delay_ms_rand)
|
||||||
|
|
||||||
|
Process.send_after(self(), :show_join_success, delay_ms)
|
||||||
|
{:noreply, socket}
|
||||||
|
|
||||||
|
{:error, :email_delivery_failed} ->
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(
|
||||||
|
:error,
|
||||||
|
gettext(
|
||||||
|
"We could not send the confirmation email. Please try again later or contact support."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|> assign(:form, to_form(params, as: "join"))}
|
||||||
|
|
||||||
|
{:error, _} ->
|
||||||
|
validation_error_reply(socket, params)
|
||||||
end
|
end
|
||||||
|
|
||||||
{:error, message} ->
|
{:error, message} ->
|
||||||
|
|
@ -161,6 +197,16 @@ defmodule MvWeb.JoinLive do
|
||||||
|> assign(:form, to_form(params, as: "join"))}
|
|> assign(:form, to_form(params, as: "join"))}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info(:show_join_success, socket) do
|
||||||
|
{:noreply, assign(socket, :submitted, true)}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Swoosh (e.g. in test) may send {:email, email} to the LiveView process; ignore.
|
||||||
|
def handle_info(_msg, socket) do
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
defp rate_limited_reply(socket, params) do
|
defp rate_limited_reply(socket, params) do
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,24 @@ defmodule MvWeb.JoinRequestLive.Helpers do
|
||||||
@doc """
|
@doc """
|
||||||
Returns the reviewer display string (e.g. email) for a join request, or nil if none.
|
Returns the reviewer display string (e.g. email) for a join request, or nil if none.
|
||||||
|
|
||||||
Accepts a join request struct or map with optional :reviewed_by_user (loaded User struct).
|
Prefers the denormalized :reviewed_by_display (set on approve/reject) so the UI
|
||||||
|
works for all roles without loading the User resource. Falls back to
|
||||||
|
:reviewed_by_user when loaded (e.g. admin or legacy data before backfill).
|
||||||
"""
|
"""
|
||||||
def reviewer_display(req) when is_map(req) do
|
def reviewer_display(req) when is_map(req) do
|
||||||
|
case Map.get(req, :reviewed_by_display) do
|
||||||
|
s when is_binary(s) ->
|
||||||
|
trimmed = String.trim(s)
|
||||||
|
if trimmed == "", do: reviewer_display_from_user(req), else: trimmed
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
reviewer_display_from_user(req)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def reviewer_display(_), do: nil
|
||||||
|
|
||||||
|
defp reviewer_display_from_user(req) do
|
||||||
user = Map.get(req, :reviewed_by_user)
|
user = Map.get(req, :reviewed_by_user)
|
||||||
|
|
||||||
case user do
|
case user do
|
||||||
|
|
@ -42,6 +57,4 @@ defmodule MvWeb.JoinRequestLive.Helpers do
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def reviewer_display(_), do: nil
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ defmodule MvWeb.JoinRequestLive.Index do
|
||||||
~H"""
|
~H"""
|
||||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||||
<.header>
|
<.header>
|
||||||
{gettext("Join requests")}
|
{@content_title}
|
||||||
</.header>
|
</.header>
|
||||||
|
|
||||||
<div class="mt-6 space-y-8 max-w-4xl">
|
<div class="mt-6 space-y-8 max-w-4xl">
|
||||||
|
|
@ -63,7 +63,7 @@ defmodule MvWeb.JoinRequestLive.Index do
|
||||||
>
|
>
|
||||||
<:col :let={req} label={gettext("Submitted at")}>
|
<:col :let={req} label={gettext("Submitted at")}>
|
||||||
<%= if req.submitted_at do %>
|
<%= if req.submitted_at do %>
|
||||||
{DateFormatter.format_datetime(req.submitted_at)}
|
{DateFormatter.format_datetime(req.submitted_at, @browser_timezone)}
|
||||||
<% else %>
|
<% else %>
|
||||||
<.empty_cell sr_text={gettext("Not submitted yet")} />
|
<.empty_cell sr_text={gettext("Not submitted yet")} />
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
@ -125,7 +125,7 @@ defmodule MvWeb.JoinRequestLive.Index do
|
||||||
</.badge>
|
</.badge>
|
||||||
</:col>
|
</:col>
|
||||||
<:col :let={req} label={gettext("Reviewed at")}>
|
<:col :let={req} label={gettext("Reviewed at")}>
|
||||||
{review_date(req)}
|
{review_date(req, @browser_timezone)}
|
||||||
</:col>
|
</:col>
|
||||||
<:col :let={req} label={gettext("Review by")}>
|
<:col :let={req} label={gettext("Review by")}>
|
||||||
{JoinRequestHelpers.reviewer_display(req) || ""}
|
{JoinRequestHelpers.reviewer_display(req) || ""}
|
||||||
|
|
@ -159,10 +159,10 @@ defmodule MvWeb.JoinRequestLive.Index do
|
||||||
assign(socket, :join_requests_history, [])
|
assign(socket, :join_requests_history, [])
|
||||||
end
|
end
|
||||||
|
|
||||||
assign(socket, :page_title, gettext("Join requests"))
|
Layouts.assign_page_title(socket, gettext("Join requests"))
|
||||||
end
|
end
|
||||||
|
|
||||||
defp review_date(req) do
|
defp review_date(req, timezone) do
|
||||||
date =
|
date =
|
||||||
case req.status do
|
case req.status do
|
||||||
:approved -> req.approved_at
|
:approved -> req.approved_at
|
||||||
|
|
@ -170,6 +170,6 @@ defmodule MvWeb.JoinRequestLive.Index do
|
||||||
_ -> nil
|
_ -> nil
|
||||||
end
|
end
|
||||||
|
|
||||||
if date, do: DateFormatter.format_datetime(date), else: ""
|
if date, do: DateFormatter.format_datetime(date, timezone), else: ""
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ defmodule MvWeb.JoinRequestLive.Show do
|
||||||
socket
|
socket
|
||||||
|> assign(:join_request, nil)
|
|> assign(:join_request, nil)
|
||||||
|> assign(:join_form_field_ids, [])
|
|> assign(:join_form_field_ids, [])
|
||||||
|> assign(:page_title, gettext("Join request"))}
|
|> Layouts.assign_page_title(gettext("Join request"))}
|
||||||
else
|
else
|
||||||
{:ok, redirect(socket, to: ~p"/members")}
|
{:ok, redirect(socket, to: ~p"/members")}
|
||||||
end
|
end
|
||||||
|
|
@ -57,7 +57,7 @@ defmodule MvWeb.JoinRequestLive.Show do
|
||||||
socket
|
socket
|
||||||
|> assign(:join_request, request)
|
|> assign(:join_request, request)
|
||||||
|> assign(:join_form_field_ids, field_ids)
|
|> assign(:join_form_field_ids, field_ids)
|
||||||
|> assign(:page_title, gettext("Join request – %{email}", email: request.email))}
|
|> Layouts.assign_page_title(gettext("Join request – %{email}", email: request.email))}
|
||||||
|
|
||||||
{:error, _error} ->
|
{:error, _error} ->
|
||||||
{:noreply,
|
{:noreply,
|
||||||
|
|
@ -123,28 +123,28 @@ defmodule MvWeb.JoinRequestLive.Show do
|
||||||
{gettext("Back")}
|
{gettext("Back")}
|
||||||
</.button>
|
</.button>
|
||||||
</:leading>
|
</:leading>
|
||||||
{gettext("Join request")}
|
{@content_title}
|
||||||
</.header>
|
</.header>
|
||||||
|
|
||||||
<%= if @join_request do %>
|
<%= if @join_request do %>
|
||||||
<div class="mt-6 space-y-6 max-w-2xl">
|
<div class="mt-6 space-y-6 max-w-2xl">
|
||||||
|
<%!-- Single block: all applicant-provided data in join form order --%>
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-lg font-semibold mb-2">{gettext("Request data")}</h2>
|
<h2 class="text-lg font-semibold mb-2">{gettext("Applicant data")}</h2>
|
||||||
|
<div class="border border-base-300 rounded-lg p-4 bg-base-100 space-y-2">
|
||||||
|
<%= for {label, value} <- applicant_data_rows(@join_request, @join_form_field_ids || []) do %>
|
||||||
|
<.field_row label={label} value={value} empty_text={gettext("Not specified")} />
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%!-- Status and review (submitted_at, status; if decided: approved/rejected at, reviewed by) --%>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold mb-2">{gettext("Status and review")}</h2>
|
||||||
<div class="border border-base-300 rounded-lg p-4 bg-base-100 space-y-2">
|
<div class="border border-base-300 rounded-lg p-4 bg-base-100 space-y-2">
|
||||||
<.field_row label={gettext("Email")} value={@join_request.email} />
|
|
||||||
<.field_row
|
|
||||||
label={gettext("First name")}
|
|
||||||
value={@join_request.first_name}
|
|
||||||
empty_text={gettext("Not specified")}
|
|
||||||
/>
|
|
||||||
<.field_row
|
|
||||||
label={gettext("Last name")}
|
|
||||||
value={@join_request.last_name}
|
|
||||||
empty_text={gettext("Not specified")}
|
|
||||||
/>
|
|
||||||
<.field_row
|
<.field_row
|
||||||
label={gettext("Submitted at")}
|
label={gettext("Submitted at")}
|
||||||
value={DateFormatter.format_datetime(@join_request.submitted_at)}
|
value={DateFormatter.format_datetime(@join_request.submitted_at, @browser_timezone)}
|
||||||
/>
|
/>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<span class="text-base-content/60 min-w-32 shrink-0">{gettext("Status")}:</span>
|
<span class="text-base-content/60 min-w-32 shrink-0">{gettext("Status")}:</span>
|
||||||
|
|
@ -154,34 +154,21 @@ defmodule MvWeb.JoinRequestLive.Show do
|
||||||
</.badge>
|
</.badge>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%= if map_size(@join_request.form_data || %{}) > 0 do %>
|
|
||||||
<div>
|
|
||||||
<h2 class="text-lg font-semibold mb-2">{gettext("Additional form data")}</h2>
|
|
||||||
<div class="border border-base-300 rounded-lg p-4 bg-base-100 space-y-2">
|
|
||||||
<%= for {key, value} <- format_form_data(@join_request.form_data, @join_form_field_ids || []) do %>
|
|
||||||
<.field_row label={key} value={to_string(value)} />
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<%= if @join_request.status in [:approved, :rejected] do %>
|
<%= if @join_request.status in [:approved, :rejected] do %>
|
||||||
<div>
|
|
||||||
<h2 class="text-lg font-semibold mb-2">{gettext("Review information")}</h2>
|
|
||||||
<div class="border border-base-300 rounded-lg p-4 bg-base-100 space-y-2">
|
|
||||||
<%= if @join_request.approved_at do %>
|
<%= if @join_request.approved_at do %>
|
||||||
<.field_row
|
<.field_row
|
||||||
label={gettext("Approved at")}
|
label={gettext("Approved at")}
|
||||||
value={DateFormatter.format_datetime(@join_request.approved_at)}
|
value={
|
||||||
|
DateFormatter.format_datetime(@join_request.approved_at, @browser_timezone)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<% end %>
|
<% end %>
|
||||||
<%= if @join_request.rejected_at do %>
|
<%= if @join_request.rejected_at do %>
|
||||||
<.field_row
|
<.field_row
|
||||||
label={gettext("Rejected at")}
|
label={gettext("Rejected at")}
|
||||||
value={DateFormatter.format_datetime(@join_request.rejected_at)}
|
value={
|
||||||
|
DateFormatter.format_datetime(@join_request.rejected_at, @browser_timezone)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<% end %>
|
<% end %>
|
||||||
<.field_row
|
<.field_row
|
||||||
|
|
@ -189,9 +176,9 @@ defmodule MvWeb.JoinRequestLive.Show do
|
||||||
value={JoinRequestHelpers.reviewer_display(@join_request)}
|
value={JoinRequestHelpers.reviewer_display(@join_request)}
|
||||||
empty_text="-"
|
empty_text="-"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<%= if @join_request.status == :submitted do %>
|
<%= if @join_request.status == :submitted do %>
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3 pt-2">
|
<div class="flex flex-wrap items-center justify-between gap-3 pt-2">
|
||||||
|
|
@ -240,40 +227,78 @@ defmodule MvWeb.JoinRequestLive.Show do
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
# Formats form_data for display in join-form order; legacy keys (not in current
|
# Builds a single list of {label, display_value} for all applicant-provided data in join form
|
||||||
# join_form_field_ids) are appended at the end, sorted by label for stability.
|
# order. Typed fields (email, first_name, last_name) and form_data are merged; legacy
|
||||||
# Labels: member field keys → human-readable; UUID keys kept as-is (custom field IDs).
|
# form_data keys (not in current join form config) are appended at the end.
|
||||||
defp format_form_data(nil, _ordered_field_ids), do: []
|
defp applicant_data_rows(join_request, ordered_field_ids) do
|
||||||
|
|
||||||
defp format_form_data(form_data, ordered_field_ids) when is_map(form_data) do
|
|
||||||
member_field_strings = Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
member_field_strings = Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
||||||
|
form_data = join_request.form_data || %{}
|
||||||
|
|
||||||
|
typed = %{
|
||||||
|
"email" => join_request.email,
|
||||||
|
"first_name" => join_request.first_name,
|
||||||
|
"last_name" => join_request.last_name
|
||||||
|
}
|
||||||
|
|
||||||
# First: entries in current join form order (only keys present in form_data)
|
|
||||||
in_order =
|
in_order =
|
||||||
ordered_field_ids
|
ordered_field_ids
|
||||||
|> Enum.filter(&Map.has_key?(form_data, &1))
|
|
||||||
|> Enum.map(fn key ->
|
|> Enum.map(fn key ->
|
||||||
value = form_data[key]
|
value = Map.get(typed, key) || Map.get(form_data, key)
|
||||||
label = field_key_to_label(key, member_field_strings)
|
label = field_key_to_label(key, member_field_strings)
|
||||||
{label, value}
|
{label, format_applicant_value(value)}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
# Then: keys in form_data that are not in current settings (e.g. removed fields on old requests)
|
|
||||||
legacy_keys =
|
legacy_keys =
|
||||||
form_data
|
form_data
|
||||||
|> Map.keys()
|
|> Map.keys()
|
||||||
|> Enum.reject(&(&1 in ordered_field_ids))
|
|> Enum.reject(fn k ->
|
||||||
|
k in ordered_field_ids or k in ["email", "first_name", "last_name"]
|
||||||
|
end)
|
||||||
|> Enum.sort()
|
|> Enum.sort()
|
||||||
|
|
||||||
legacy_entries =
|
legacy_entries =
|
||||||
Enum.map(legacy_keys, fn key ->
|
Enum.map(legacy_keys, fn key ->
|
||||||
label = field_key_to_label(key, member_field_strings)
|
label = field_key_to_label(key, member_field_strings)
|
||||||
{label, form_data[key]}
|
{label, format_applicant_value(form_data[key])}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
in_order ++ legacy_entries
|
in_order ++ legacy_entries
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp format_applicant_value(nil), do: nil
|
||||||
|
defp format_applicant_value(""), do: nil
|
||||||
|
defp format_applicant_value(%Date{} = date), do: DateFormatter.format_date(date)
|
||||||
|
|
||||||
|
defp format_applicant_value(value) when is_map(value),
|
||||||
|
do: format_applicant_value_from_map(value)
|
||||||
|
|
||||||
|
defp format_applicant_value(value) when is_boolean(value),
|
||||||
|
do: if(value, do: gettext("Yes"), else: gettext("No"))
|
||||||
|
|
||||||
|
defp format_applicant_value(value) when is_binary(value) or is_number(value),
|
||||||
|
do: to_string(value)
|
||||||
|
|
||||||
|
defp format_applicant_value(value), do: to_string(value)
|
||||||
|
|
||||||
|
defp format_applicant_value_from_map(value) do
|
||||||
|
raw = Map.get(value, "_union_value") || Map.get(value, "value")
|
||||||
|
type = Map.get(value, "_union_type") || Map.get(value, "type")
|
||||||
|
|
||||||
|
if raw && type in ["date", :date] do
|
||||||
|
format_applicant_value(raw)
|
||||||
|
else
|
||||||
|
format_applicant_value_simple(raw, value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_applicant_value_simple(raw, _value) when is_binary(raw), do: raw
|
||||||
|
|
||||||
|
defp format_applicant_value_simple(raw, _value) when is_boolean(raw),
|
||||||
|
do: if(raw, do: gettext("Yes"), else: gettext("No"))
|
||||||
|
|
||||||
|
defp format_applicant_value_simple(raw, _value) when is_integer(raw), do: to_string(raw)
|
||||||
|
defp format_applicant_value_simple(_raw, value), do: to_string(value)
|
||||||
|
|
||||||
defp field_key_to_label(key, member_field_strings) when is_binary(key) do
|
defp field_key_to_label(key, member_field_strings) when is_binary(key) do
|
||||||
if key in member_field_strings,
|
if key in member_field_strings,
|
||||||
do: MemberFieldsTranslations.label(String.to_existing_atom(key)),
|
do: MemberFieldsTranslations.label(String.to_existing_atom(key)),
|
||||||
|
|
|
||||||
|
|
@ -374,7 +374,7 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
id -> Ash.get!(MemberResource, id, load: [:membership_fee_type], actor: actor)
|
id -> Ash.get!(MemberResource, id, load: [:membership_fee_type], actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
page_title =
|
content_title =
|
||||||
if is_nil(member), do: gettext("Create Member"), else: gettext("Edit Member")
|
if is_nil(member), do: gettext("Create Member"), else: gettext("Edit Member")
|
||||||
|
|
||||||
# Load available membership fee types
|
# Load available membership fee types
|
||||||
|
|
@ -389,7 +389,7 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
|> assign(:custom_fields, custom_fields)
|
|> assign(:custom_fields, custom_fields)
|
||||||
|> assign(:initial_custom_field_values, initial_custom_field_values)
|
|> assign(:initial_custom_field_values, initial_custom_field_values)
|
||||||
|> assign(member: member)
|
|> assign(member: member)
|
||||||
|> assign(:page_title, page_title)
|
|> Layouts.assign_page_title(content_title)
|
||||||
|> assign(:available_fee_types, available_fee_types)
|
|> assign(:available_fee_types, available_fee_types)
|
||||||
|> assign(:interval_warning, nil)
|
|> assign(:interval_warning, nil)
|
||||||
|> assign(:member_field_required_map, member_field_required_map)
|
|> assign(:member_field_required_map, member_field_required_map)
|
||||||
|
|
|
||||||
|
|
@ -127,7 +127,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, gettext("Members"))
|
|> Layouts.assign_page_title(gettext("Members"))
|
||||||
|> assign(:query, "")
|
|> assign(:query, "")
|
||||||
|> assign_new(:sort_field, fn -> :first_name end)
|
|> assign_new(:sort_field, fn -> :first_name end)
|
||||||
|> assign_new(:sort_order, fn -> :asc end)
|
|> assign_new(:sort_order, fn -> :asc end)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||||
<.header>
|
<.header>
|
||||||
{gettext("Members")}
|
{@content_title}
|
||||||
<:actions>
|
<:actions>
|
||||||
<.live_component
|
<.live_component
|
||||||
module={MvWeb.Components.ExportDropdown}
|
module={MvWeb.Components.ExportDropdown}
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
{gettext("Back")}
|
{gettext("Back")}
|
||||||
</.button>
|
</.button>
|
||||||
</:leading>
|
</:leading>
|
||||||
{MemberHelpers.display_name(@member)}
|
{@content_title}
|
||||||
<:actions>
|
<:actions>
|
||||||
<%= if can?(@current_user, :update, @member) do %>
|
<%= if can?(@current_user, :update, @member) do %>
|
||||||
<.button
|
<.button
|
||||||
|
|
@ -435,9 +435,12 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
|> Map.put(:last_cycle_status, last_cycle_status)
|
|> Map.put(:last_cycle_status, last_cycle_status)
|
||||||
|> Map.put(:current_cycle_status, current_cycle_status)
|
|> Map.put(:current_cycle_status, current_cycle_status)
|
||||||
|
|
||||||
|
content_title =
|
||||||
|
gettext("Member %{name}", name: MemberHelpers.display_name(member))
|
||||||
|
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, page_title(socket.assigns.live_action))
|
|> Layouts.assign_page_title(content_title)
|
||||||
|> assign(:member, member)}
|
|> assign(:member, member)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -565,9 +568,6 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
{:noreply, assign(socket, :member, member)}
|
{:noreply, assign(socket, :member, member)}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp page_title(:show), do: gettext("Show Member")
|
|
||||||
defp page_title(:edit), do: gettext("Edit Member")
|
|
||||||
|
|
||||||
defp format_error(%Ash.Error.Invalid{errors: errors}) do
|
defp format_error(%Ash.Error.Invalid{errors: errors}) do
|
||||||
error_messages =
|
error_messages =
|
||||||
Enum.map(errors, fn
|
Enum.map(errors, fn
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ defmodule MvWeb.MembershipFeeSettingsLive do
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, gettext("Membership Fee Settings"))
|
|> Layouts.assign_page_title(gettext("Membership fee settings"))
|
||||||
|> assign(:settings, settings)
|
|> assign(:settings, settings)
|
||||||
|> assign(:membership_fee_types, membership_fee_types)
|
|> assign(:membership_fee_types, membership_fee_types)
|
||||||
|> assign(:member_counts, member_counts)
|
|> assign(:member_counts, member_counts)
|
||||||
|
|
@ -140,7 +140,7 @@ defmodule MvWeb.MembershipFeeSettingsLive do
|
||||||
~H"""
|
~H"""
|
||||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||||
<.header>
|
<.header>
|
||||||
{gettext("Membership Fee Settings")}
|
{@content_title}
|
||||||
<:subtitle>
|
<:subtitle>
|
||||||
{gettext("Configure fee types for membership fees.")}
|
{gettext("Configure fee types for membership fees.")}
|
||||||
</:subtitle>
|
</:subtitle>
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
|
||||||
{gettext("Back")}
|
{gettext("Back")}
|
||||||
</.button>
|
</.button>
|
||||||
</:leading>
|
</:leading>
|
||||||
{@page_title}
|
{@content_title}
|
||||||
<:actions>
|
<:actions>
|
||||||
<.button
|
<.button
|
||||||
form="membership-fee-type-form"
|
form="membership-fee-type-form"
|
||||||
|
|
@ -221,7 +221,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
|
||||||
id -> Ash.get!(MembershipFeeType, id, domain: MembershipFees, actor: actor)
|
id -> Ash.get!(MembershipFeeType, id, domain: MembershipFees, actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
page_title =
|
content_title =
|
||||||
if is_nil(membership_fee_type),
|
if is_nil(membership_fee_type),
|
||||||
do: gettext("New Membership Fee Type"),
|
do: gettext("New Membership Fee Type"),
|
||||||
else: gettext("Edit Membership Fee Type")
|
else: gettext("Edit Membership Fee Type")
|
||||||
|
|
@ -230,7 +230,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
|
||||||
socket
|
socket
|
||||||
|> assign(:return_to, return_to(params["return_to"]))
|
|> assign(:return_to, return_to(params["return_to"]))
|
||||||
|> assign(:membership_fee_type, membership_fee_type)
|
|> assign(:membership_fee_type, membership_fee_type)
|
||||||
|> assign(:page_title, page_title)
|
|> Layouts.assign_page_title(content_title)
|
||||||
|> assign(:show_amount_warning, false)
|
|> assign(:show_amount_warning, false)
|
||||||
|> assign(:old_amount, nil)
|
|> assign(:old_amount, nil)
|
||||||
|> assign(:new_amount, nil)
|
|> assign(:new_amount, nil)
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, gettext("Membership Fee Types"))
|
|> Layouts.assign_page_title(gettext("Membership fee settings"))
|
||||||
|> assign(:membership_fee_types, fee_types)
|
|> assign(:membership_fee_types, fee_types)
|
||||||
|> assign(:member_counts, member_counts)}
|
|> assign(:member_counts, member_counts)}
|
||||||
end
|
end
|
||||||
|
|
@ -42,7 +42,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
|
||||||
~H"""
|
~H"""
|
||||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||||
<.header>
|
<.header>
|
||||||
{gettext("Membership Fee Types")}
|
{@content_title}
|
||||||
<:subtitle>
|
<:subtitle>
|
||||||
{gettext("Manage membership fee types for membership fees.")}
|
{gettext("Manage membership fee types for membership fees.")}
|
||||||
</:subtitle>
|
</:subtitle>
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ defmodule MvWeb.RoleLive.Form do
|
||||||
{gettext("Back")}
|
{gettext("Back")}
|
||||||
</.button>
|
</.button>
|
||||||
</:leading>
|
</:leading>
|
||||||
{@page_title}
|
{@content_title}
|
||||||
<:actions>
|
<:actions>
|
||||||
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
|
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
|
||||||
{gettext("Save")}
|
{gettext("Save")}
|
||||||
|
|
@ -94,14 +94,13 @@ defmodule MvWeb.RoleLive.Form do
|
||||||
def mount(params, _session, socket) do
|
def mount(params, _session, socket) do
|
||||||
case params["id"] do
|
case params["id"] do
|
||||||
nil ->
|
nil ->
|
||||||
action = gettext("New")
|
content_title = gettext("New") <> " " <> gettext("Role")
|
||||||
page_title = action <> " " <> gettext("Role")
|
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> assign(:return_to, return_to(params["return_to"]))
|
|> assign(:return_to, return_to(params["return_to"]))
|
||||||
|> assign(:role, nil)
|
|> assign(:role, nil)
|
||||||
|> assign(:page_title, page_title)
|
|> Layouts.assign_page_title(content_title)
|
||||||
|> assign_form()}
|
|> assign_form()}
|
||||||
|
|
||||||
id ->
|
id ->
|
||||||
|
|
@ -113,14 +112,13 @@ defmodule MvWeb.RoleLive.Form do
|
||||||
actor: socket.assigns[:current_user]
|
actor: socket.assigns[:current_user]
|
||||||
) do
|
) do
|
||||||
{:ok, role} ->
|
{:ok, role} ->
|
||||||
action = gettext("Edit")
|
content_title = gettext("Edit") <> " " <> gettext("Role")
|
||||||
page_title = action <> " " <> gettext("Role")
|
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> assign(:return_to, return_to(params["return_to"]))
|
|> assign(:return_to, return_to(params["return_to"]))
|
||||||
|> assign(:role, role)
|
|> assign(:role, role)
|
||||||
|> assign(:page_title, page_title)
|
|> Layouts.assign_page_title(content_title)
|
||||||
|> assign_form()}
|
|> assign_form()}
|
||||||
|
|
||||||
{:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{} | _]}} ->
|
{:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{} | _]}} ->
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ defmodule MvWeb.RoleLive.Index do
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, gettext("Listing Roles"))
|
|> Layouts.assign_page_title(gettext("Roles"))
|
||||||
|> assign(:roles, roles)
|
|> assign(:roles, roles)
|
||||||
|> assign(:user_counts, user_counts)}
|
|> assign(:user_counts, user_counts)}
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||||
<.header>
|
<.header>
|
||||||
{gettext("Listing Roles")}
|
{@content_title}
|
||||||
<:subtitle>
|
<:subtitle>
|
||||||
{gettext("Manage roles and their permission sets.")}
|
{gettext("Manage roles and their permission sets.")}
|
||||||
</:subtitle>
|
</:subtitle>
|
||||||
|
|
|
||||||
|
|
@ -30,9 +30,11 @@ defmodule MvWeb.RoleLive.Show do
|
||||||
{:ok, role} ->
|
{:ok, role} ->
|
||||||
user_count = load_user_count(role, socket.assigns[:current_user])
|
user_count = load_user_count(role, socket.assigns[:current_user])
|
||||||
|
|
||||||
|
content_title = gettext("Role %{name}", name: role.name)
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, gettext("Show Role"))
|
|> Layouts.assign_page_title(content_title)
|
||||||
|> assign(:role, role)
|
|> assign(:role, role)
|
||||||
|> assign(:user_count, user_count)
|
|> assign(:user_count, user_count)
|
||||||
|> assign(:show_delete_modal, false)}
|
|> assign(:show_delete_modal, false)}
|
||||||
|
|
@ -202,7 +204,7 @@ defmodule MvWeb.RoleLive.Show do
|
||||||
{gettext("Back")}
|
{gettext("Back")}
|
||||||
</.button>
|
</.button>
|
||||||
</:leading>
|
</:leading>
|
||||||
{gettext("Role")} {@role.name}
|
{@content_title}
|
||||||
<:subtitle>{gettext("Role details and permissions.")}</:subtitle>
|
<:subtitle>{gettext("Role details and permissions.")}</:subtitle>
|
||||||
|
|
||||||
<:actions>
|
<:actions>
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ defmodule MvWeb.StatisticsLive do
|
||||||
# Only static assigns and fee types here; load_statistics runs once in handle_params
|
# Only static assigns and fee types here; load_statistics runs once in handle_params
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, gettext("Statistics"))
|
|> Layouts.assign_page_title(gettext("Statistics"))
|
||||||
|> assign(:selected_fee_type_id, nil)
|
|> assign(:selected_fee_type_id, nil)
|
||||||
|> load_fee_types()
|
|> load_fee_types()
|
||||||
|
|
||||||
|
|
@ -58,7 +58,7 @@ defmodule MvWeb.StatisticsLive do
|
||||||
~H"""
|
~H"""
|
||||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||||
<.header>
|
<.header>
|
||||||
{gettext("Statistics")}
|
{@content_title}
|
||||||
</.header>
|
</.header>
|
||||||
|
|
||||||
<section class="mb-8" aria-labelledby="members-heading">
|
<section class="mb-8" aria-labelledby="members-heading">
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ defmodule MvWeb.UserLive.Form do
|
||||||
{gettext("Back")}
|
{gettext("Back")}
|
||||||
</.button>
|
</.button>
|
||||||
</:leading>
|
</:leading>
|
||||||
{@page_title}
|
{@content_title}
|
||||||
<:actions>
|
<:actions>
|
||||||
<.button
|
<.button
|
||||||
form="user-form"
|
form="user-form"
|
||||||
|
|
@ -423,8 +423,9 @@ defmodule MvWeb.UserLive.Form do
|
||||||
|
|
||||||
defp mount_continue(user, params, socket) do
|
defp mount_continue(user, params, socket) do
|
||||||
actor = current_actor(socket)
|
actor = current_actor(socket)
|
||||||
action = if is_nil(user), do: gettext("New"), else: gettext("Edit")
|
|
||||||
page_title = action <> " " <> gettext("User")
|
content_title =
|
||||||
|
if(is_nil(user), do: gettext("New"), else: gettext("Edit")) <> " " <> gettext("User")
|
||||||
|
|
||||||
# Only admins can link/unlink users to members (permission docs; prevents privilege escalation).
|
# Only admins can link/unlink users to members (permission docs; prevents privilege escalation).
|
||||||
can_manage_member_linking = can?(actor, :destroy, UserResource)
|
can_manage_member_linking = can?(actor, :destroy, UserResource)
|
||||||
|
|
@ -436,7 +437,7 @@ defmodule MvWeb.UserLive.Form do
|
||||||
socket
|
socket
|
||||||
|> assign(:return_to, return_to(params["return_to"]))
|
|> assign(:return_to, return_to(params["return_to"]))
|
||||||
|> assign(user: user)
|
|> assign(user: user)
|
||||||
|> assign(:page_title, page_title)
|
|> Layouts.assign_page_title(content_title)
|
||||||
|> assign(:can_manage_member_linking, can_manage_member_linking)
|
|> assign(:can_manage_member_linking, can_manage_member_linking)
|
||||||
|> assign(:can_assign_role, can_assign_role)
|
|> assign(:can_assign_role, can_assign_role)
|
||||||
|> assign(:roles, roles)
|
|> assign(:roles, roles)
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ defmodule MvWeb.UserLive.Index do
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, gettext("Listing Users"))
|
|> Layouts.assign_page_title(gettext("Users"))
|
||||||
|> assign(:sort_field, :email)
|
|> assign(:sort_field, :email)
|
||||||
|> assign(:sort_order, :asc)
|
|> assign(:sort_order, :asc)
|
||||||
|> assign(:users, sorted)}
|
|> assign(:users, sorted)}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||||
<.header>
|
<.header>
|
||||||
{gettext("Users")}
|
{@content_title}
|
||||||
<:subtitle>{gettext("Manage users and their permissions.")}</:subtitle>
|
<:subtitle>{gettext("Manage users and their permissions.")}</:subtitle>
|
||||||
<:actions>
|
<:actions>
|
||||||
<%= if can?(@current_user, :create, Mv.Accounts.User) do %>
|
<%= if can?(@current_user, :create, Mv.Accounts.User) do %>
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ defmodule MvWeb.UserLive.Show do
|
||||||
{gettext("Back")}
|
{gettext("Back")}
|
||||||
</.button>
|
</.button>
|
||||||
</:leading>
|
</:leading>
|
||||||
{gettext("User")} {@user.email}
|
{@content_title}
|
||||||
<:actions>
|
<:actions>
|
||||||
<%= if can?(@current_user, :update, @user) do %>
|
<%= if can?(@current_user, :update, @user) do %>
|
||||||
<.button
|
<.button
|
||||||
|
|
@ -179,9 +179,11 @@ defmodule MvWeb.UserLive.Show do
|
||||||
|> put_flash(:error, gettext("This user cannot be viewed."))
|
|> put_flash(:error, gettext("This user cannot be viewed."))
|
||||||
|> push_navigate(to: ~p"/users")}
|
|> push_navigate(to: ~p"/users")}
|
||||||
else
|
else
|
||||||
|
content_title = gettext("User %{email}", email: user.email)
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, gettext("Show User"))
|
|> Layouts.assign_page_title(content_title)
|
||||||
|> assign(:user, user)
|
|> assign(:user, user)
|
||||||
|> assign(:show_delete_modal, false)}
|
|> assign(:show_delete_modal, false)}
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -17,11 +17,29 @@ defmodule MvWeb.LiveHelpers do
|
||||||
"""
|
"""
|
||||||
import Phoenix.Component
|
import Phoenix.Component
|
||||||
alias Mv.Authorization.Actor
|
alias Mv.Authorization.Actor
|
||||||
|
alias Mv.Membership
|
||||||
alias MvWeb.Plugs.CheckPagePermission
|
alias MvWeb.Plugs.CheckPagePermission
|
||||||
|
|
||||||
def on_mount(:default, _params, session, socket) do
|
def on_mount(:default, _params, session, socket) do
|
||||||
locale = session["locale"] || "de"
|
locale = session["locale"] || "de"
|
||||||
Gettext.put_locale(locale)
|
Gettext.put_locale(locale)
|
||||||
|
|
||||||
|
# Browser timezone from LiveSocket connect params (set in app.js via Intl API)
|
||||||
|
connect_params = socket.private[:connect_params] || %{}
|
||||||
|
timezone = connect_params["timezone"] || connect_params[:timezone]
|
||||||
|
|
||||||
|
# Club name for browser tab title (Mila · Club · Page)
|
||||||
|
club_name =
|
||||||
|
case Membership.get_settings() do
|
||||||
|
{:ok, settings} when is_binary(settings.club_name) -> settings.club_name
|
||||||
|
_ -> nil
|
||||||
|
end
|
||||||
|
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(:browser_timezone, timezone)
|
||||||
|
|> assign(:club_name, club_name)
|
||||||
|
|
||||||
{:cont, socket}
|
{:cont, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -56,7 +74,7 @@ defmodule MvWeb.LiveHelpers do
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> Phoenix.LiveView.put_flash(:error, "You don't have permission to access this page.")
|
|> maybe_put_access_denied_flash(user)
|
||||||
|> Phoenix.LiveView.push_navigate(to: redirect_to)
|
|> Phoenix.LiveView.push_navigate(to: redirect_to)
|
||||||
|
|
||||||
{:halt, socket}
|
{:halt, socket}
|
||||||
|
|
@ -64,6 +82,13 @@ defmodule MvWeb.LiveHelpers do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Only show "no permission" when user is logged in; unauthenticated users are redirected to sign-in without flash.
|
||||||
|
defp maybe_put_access_denied_flash(socket, nil), do: socket
|
||||||
|
|
||||||
|
defp maybe_put_access_denied_flash(socket, _user) do
|
||||||
|
Phoenix.LiveView.put_flash(socket, :error, "You don't have permission to access this page.")
|
||||||
|
end
|
||||||
|
|
||||||
defp ensure_user_role_loaded(socket) do
|
defp ensure_user_role_loaded(socket) do
|
||||||
user = socket.assigns[:current_user]
|
user = socket.assigns[:current_user]
|
||||||
|
|
||||||
|
|
|
||||||
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
|
||||||
|
|
@ -54,7 +54,7 @@ defmodule MvWeb.Plugs.CheckPagePermission do
|
||||||
conn
|
conn
|
||||||
|> fetch_session()
|
|> fetch_session()
|
||||||
|> fetch_flash()
|
|> fetch_flash()
|
||||||
|> put_flash(:error, "You don't have permission to access this page.")
|
|> maybe_put_access_denied_flash(user)
|
||||||
|> redirect(to: redirect_to)
|
|> redirect(to: redirect_to)
|
||||||
|> halt()
|
|> halt()
|
||||||
end
|
end
|
||||||
|
|
@ -75,6 +75,13 @@ defmodule MvWeb.Plugs.CheckPagePermission do
|
||||||
|
|
||||||
defp redirect_target(user), do: redirect_target_for_user(user)
|
defp redirect_target(user), do: redirect_target_for_user(user)
|
||||||
|
|
||||||
|
# Only set "no permission" flash when user is logged in; unauthenticated users get redirect only, no flash.
|
||||||
|
defp maybe_put_access_denied_flash(conn, nil), do: conn
|
||||||
|
|
||||||
|
defp maybe_put_access_denied_flash(conn, _user) do
|
||||||
|
put_flash(conn, :error, "You don't have permission to access this page.")
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns true if the path is public (no auth/permission check).
|
Returns true if the path is public (no auth/permission check).
|
||||||
Used by LiveView hook to skip redirect on sign-in etc.
|
Used by LiveView hook to skip redirect on sign-in etc.
|
||||||
|
|
|
||||||
73
lib/mv_web/plugs/oidc_only_sign_in_redirect.ex
Normal file
73
lib/mv_web/plugs/oidc_only_sign_in_redirect.ex
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
defmodule MvWeb.Plugs.OidcOnlySignInRedirect do
|
||||||
|
@moduledoc """
|
||||||
|
When OIDC-only mode is active:
|
||||||
|
- GET /sign-in redirects to the OIDC flow when OIDC is configured (sign-in page skipped).
|
||||||
|
- GET /sign-in?oidc_failed=1 is not redirected, so the sign-in page is shown after an OIDC
|
||||||
|
failure (avoids redirect loop when the provider is down or misconfigured).
|
||||||
|
- GET /auth/user/password/sign_in_with_token is rejected (redirect to /sign-in with error)
|
||||||
|
so password sign-in cannot complete.
|
||||||
|
"""
|
||||||
|
import Plug.Conn
|
||||||
|
import Phoenix.Controller
|
||||||
|
|
||||||
|
alias Mv.Config
|
||||||
|
|
||||||
|
def init(opts), do: opts
|
||||||
|
|
||||||
|
def call(conn, _opts) do
|
||||||
|
conn
|
||||||
|
|> maybe_redirect_sign_in_to_oidc()
|
||||||
|
|> maybe_reject_password_token_sign_in()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_redirect_sign_in_to_oidc(conn) do
|
||||||
|
if conn.request_path != "/sign-in" or conn.method != "GET" do
|
||||||
|
conn
|
||||||
|
else
|
||||||
|
conn = fetch_query_params(conn)
|
||||||
|
maybe_redirect_sign_in_to_oidc_checked(conn)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_redirect_sign_in_to_oidc_checked(conn) do
|
||||||
|
cond do
|
||||||
|
# Show sign-in page when returning from OIDC failure to avoid redirect loop.
|
||||||
|
conn.query_params["oidc_failed"] -> conn
|
||||||
|
Config.oidc_only?() and Config.oidc_configured?() -> redirect_and_halt(conn)
|
||||||
|
true -> conn
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp redirect_and_halt(conn) do
|
||||||
|
conn
|
||||||
|
|> redirect(to: "/auth/user/oidc")
|
||||||
|
|> halt()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_reject_password_token_sign_in(conn) do
|
||||||
|
if conn.halted, do: conn, else: reject_password_token_sign_in_if_applicable(conn)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp reject_password_token_sign_in_if_applicable(conn) do
|
||||||
|
path = conn.request_path
|
||||||
|
|
||||||
|
password_token_path? =
|
||||||
|
path =~ ~r|/auth/user/password/sign_in_with_token| and conn.method == "GET"
|
||||||
|
|
||||||
|
if password_token_path? and Config.oidc_only?() do
|
||||||
|
message =
|
||||||
|
Gettext.dgettext(
|
||||||
|
MvWeb.Gettext,
|
||||||
|
"default",
|
||||||
|
"Only sign-in via Single Sign-On (SSO) is allowed."
|
||||||
|
)
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> put_flash(:error, message)
|
||||||
|
|> redirect(to: "/sign-in")
|
||||||
|
|> halt()
|
||||||
|
else
|
||||||
|
conn
|
||||||
|
end
|
||||||
|
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,11 @@ defmodule MvWeb.Router do
|
||||||
plug :put_secure_browser_headers
|
plug :put_secure_browser_headers
|
||||||
plug :load_from_session
|
plug :load_from_session
|
||||||
plug :set_locale
|
plug :set_locale
|
||||||
|
plug MvWeb.Plugs.AssignClubName
|
||||||
plug MvWeb.Plugs.CheckPagePermission
|
plug MvWeb.Plugs.CheckPagePermission
|
||||||
plug MvWeb.Plugs.JoinFormEnabled
|
plug MvWeb.Plugs.JoinFormEnabled
|
||||||
|
plug MvWeb.Plugs.RegistrationEnabled
|
||||||
|
plug MvWeb.Plugs.OidcOnlySignInRedirect
|
||||||
end
|
end
|
||||||
|
|
||||||
pipeline :api do
|
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;">
|
<div style="color: #111827;">
|
||||||
|
<%= if @resend do %>
|
||||||
|
<p style="margin: 0 0 16px; font-size: 16px; line-height: 1.5;">
|
||||||
|
{gettext("You already had a pending request. Here is a new confirmation link.")}
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
<p style="margin: 0 0 16px; font-size: 16px; line-height: 1.5;">
|
<p style="margin: 0 0 16px; font-size: 16px; line-height: 1.5;">
|
||||||
{gettext(
|
{gettext(
|
||||||
"We have received your membership request. To complete it, please click the link below."
|
"We have received your membership request. To complete it, please click the link below."
|
||||||
|
|
|
||||||
5
mix.exs
5
mix.exs
|
|
@ -67,6 +67,8 @@ defmodule Mv.MixProject do
|
||||||
depth: 1},
|
depth: 1},
|
||||||
{:phoenix_swoosh, "~> 1.0"},
|
{:phoenix_swoosh, "~> 1.0"},
|
||||||
{:swoosh, "~> 1.16"},
|
{:swoosh, "~> 1.16"},
|
||||||
|
# Required by Swoosh.Adapters.SMTP (and its Helpers use mimemail, which gen_smtp brings in)
|
||||||
|
{:gen_smtp, "~> 1.0"},
|
||||||
{:req, "~> 0.5"},
|
{:req, "~> 0.5"},
|
||||||
{:telemetry_metrics, "~> 1.0"},
|
{:telemetry_metrics, "~> 1.0"},
|
||||||
{:telemetry_poller, "~> 1.0"},
|
{:telemetry_poller, "~> 1.0"},
|
||||||
|
|
@ -83,7 +85,8 @@ defmodule Mv.MixProject do
|
||||||
{:slugify, "~> 1.3"},
|
{:slugify, "~> 1.3"},
|
||||||
{:nimble_csv, "~> 1.0"},
|
{:nimble_csv, "~> 1.0"},
|
||||||
{:imprintor, "~> 0.5.0"},
|
{:imprintor, "~> 0.5.0"},
|
||||||
{:hammer, "~> 7.0"}
|
{:hammer, "~> 7.0"},
|
||||||
|
{:tz, "~> 0.28"}
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
2
mix.lock
2
mix.lock
|
|
@ -35,6 +35,7 @@
|
||||||
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
|
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
|
||||||
"finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"},
|
"finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"},
|
||||||
"fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"},
|
"fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"},
|
||||||
|
"gen_smtp": {:hex, :gen_smtp, "1.3.0", "62c3d91f0dcf6ce9db71bcb6881d7ad0d1d834c7f38c13fa8e952f4104a8442e", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "0b73fbf069864ecbce02fe653b16d3f35fd889d0fdd4e14527675565c39d84e6"},
|
||||||
"gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"},
|
"gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"},
|
||||||
"glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"},
|
"glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"},
|
||||||
"hammer": {:hex, :hammer, "7.2.0", "73113eca87f0fd20a6d3679c1182e8c4c1778266f61de4e9dc8c589dee156c30", [:mix], [], "hexpm", "c50fa865ddfe7b3d4f8a6941f56940679e02a9a1465b00668a95d140b101d828"},
|
"hammer": {:hex, :hammer, "7.2.0", "73113eca87f0fd20a6d3679c1182e8c4c1778266f61de4e9dc8c589dee156c30", [:mix], [], "hexpm", "c50fa865ddfe7b3d4f8a6941f56940679e02a9a1465b00668a95d140b101d828"},
|
||||||
|
|
@ -95,6 +96,7 @@
|
||||||
"text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"},
|
"text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"},
|
||||||
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
|
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
|
||||||
"tidewave": {:hex, :tidewave, "0.5.5", "a125dfc87f99daf0e2280b3a9719b874c616ead5926cdf9cdfe4fcc19a020eff", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "825ebb4fa20de005785efa21e5a88c04d81c3f57552638d12ff3def2f203dbf7"},
|
"tidewave": {:hex, :tidewave, "0.5.5", "a125dfc87f99daf0e2280b3a9719b874c616ead5926cdf9cdfe4fcc19a020eff", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "825ebb4fa20de005785efa21e5a88c04d81c3f57552638d12ff3def2f203dbf7"},
|
||||||
|
"tz": {:hex, :tz, "0.28.1", "717f5ffddfd1e475e2a233e221dc0b4b76c35c4b3650b060c8e3ba29dd6632e9", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:mint, "~> 1.6", [hex: :mint, repo: "hexpm", optional: true]}], "hexpm", "bfdca1aa1902643c6c43b77c1fb0cb3d744fd2f09a8a98405468afdee0848c8a"},
|
||||||
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
|
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
|
||||||
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
|
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
|
||||||
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
|
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
|
||||||
|
|
|
||||||
|
|
@ -139,18 +139,16 @@ msgid "This OIDC account is already linked to another user. Please contact suppo
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||||
#: lib/mv_web/live/auth/sign_in_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Language selection"
|
msgid "Language selection"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||||
#: lib/mv_web/live/auth/sign_in_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Select language"
|
msgid "Select language"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/auth_overrides.ex
|
#: lib/mv_web/live/auth/sign_in_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "or"
|
msgid "Register"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
|
||||||
|
|
@ -135,18 +135,16 @@ msgid "This OIDC account is already linked to another user. Please contact suppo
|
||||||
msgstr "Dieses OIDC-Konto ist bereits mit einer*m anderen Benutzer*in verknüpft. Bitte kontaktiere den Support."
|
msgstr "Dieses OIDC-Konto ist bereits mit einer*m anderen Benutzer*in verknüpft. Bitte kontaktiere den Support."
|
||||||
|
|
||||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||||
#: lib/mv_web/live/auth/sign_in_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Language selection"
|
msgid "Language selection"
|
||||||
msgstr "Sprachauswahl"
|
msgstr "Sprachauswahl"
|
||||||
|
|
||||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||||
#: lib/mv_web/live/auth/sign_in_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Select language"
|
msgid "Select language"
|
||||||
msgstr "Sprache auswählen"
|
msgstr "Sprache auswählen"
|
||||||
|
|
||||||
#: lib/mv_web/auth_overrides.ex
|
#: lib/mv_web/live/auth/sign_in_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "or"
|
msgid "Register"
|
||||||
msgstr "oder"
|
msgstr "Registrieren"
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -132,18 +132,16 @@ msgid "This OIDC account is already linked to another user. Please contact suppo
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||||
#: lib/mv_web/live/auth/sign_in_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Language selection"
|
msgid "Language selection"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||||
#: lib/mv_web/live/auth/sign_in_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Select language"
|
msgid "Select language"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/auth_overrides.ex
|
#: lib/mv_web/live/auth/sign_in_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "or"
|
msgid "Register"
|
||||||
msgstr "or"
|
msgstr ""
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
27
priv/repo/migrations/20260311082352_add_smtp_to_settings.exs
Normal file
27
priv/repo/migrations/20260311082352_add_smtp_to_settings.exs
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
defmodule Mv.Repo.Migrations.AddSmtpToSettings do
|
||||||
|
@moduledoc """
|
||||||
|
Adds SMTP configuration attributes to the settings table.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def up do
|
||||||
|
alter table(:settings) do
|
||||||
|
add :smtp_host, :text
|
||||||
|
add :smtp_port, :bigint
|
||||||
|
add :smtp_username, :text
|
||||||
|
add :smtp_password, :text
|
||||||
|
add :smtp_ssl, :text
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def down do
|
||||||
|
alter table(:settings) do
|
||||||
|
remove :smtp_ssl
|
||||||
|
remove :smtp_password
|
||||||
|
remove :smtp_username
|
||||||
|
remove :smtp_port
|
||||||
|
remove :smtp_host
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
defmodule Mv.Repo.Migrations.AddMailFromToSettings do
|
||||||
|
@moduledoc "Adds smtp_from_name and smtp_from_email attributes to the settings table."
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def up do
|
||||||
|
alter table(:settings) do
|
||||||
|
add :smtp_from_name, :text
|
||||||
|
add :smtp_from_email, :text
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def down do
|
||||||
|
alter table(:settings) do
|
||||||
|
remove :smtp_from_email
|
||||||
|
remove :smtp_from_name
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -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
|
||||||
|
|
@ -3,7 +3,9 @@
|
||||||
# mix run priv/repo/seeds.exs
|
# mix run priv/repo/seeds.exs
|
||||||
#
|
#
|
||||||
# Bootstrap runs in all environments. Dev seeds (members, groups, sample data)
|
# Bootstrap runs in all environments. Dev seeds (members, groups, sample data)
|
||||||
# run only in dev and test.
|
# run only in dev and test. Skips entirely if bootstrap was already applied
|
||||||
|
# (admin user exists), so safe to run on every start. Set FORCE_SEEDS=true to
|
||||||
|
# re-run seeds even when already applied.
|
||||||
#
|
#
|
||||||
# In production (release): seeds are run via Mv.Release.run_seeds/0 from the
|
# In production (release): seeds are run via Mv.Release.run_seeds/0 from the
|
||||||
# container entrypoint. Set RUN_DEV_SEEDS=true to also run dev seeds there.
|
# container entrypoint. Set RUN_DEV_SEEDS=true to also run dev seeds there.
|
||||||
|
|
@ -12,10 +14,15 @@
|
||||||
# so that eval_file of bootstrap/dev does not emit "redefining module" warnings;
|
# so that eval_file of bootstrap/dev does not emit "redefining module" warnings;
|
||||||
# it is always restored in `after` to avoid hiding real conflicts elsewhere.
|
# it is always restored in `after` to avoid hiding real conflicts elsewhere.
|
||||||
|
|
||||||
prev = Code.compiler_options()
|
_ = Application.ensure_all_started(:mv)
|
||||||
Code.compiler_options(ignore_module_conflict: true)
|
|
||||||
|
|
||||||
try do
|
if Mv.Release.bootstrap_seeds_applied?() and System.get_env("FORCE_SEEDS") != "true" do
|
||||||
|
IO.puts("Seeds already applied. Skipping. (Set FORCE_SEEDS=true to override)")
|
||||||
|
else
|
||||||
|
prev = Code.compiler_options()
|
||||||
|
Code.compiler_options(ignore_module_conflict: true)
|
||||||
|
|
||||||
|
try do
|
||||||
# Always run bootstrap (fee types, custom fields, roles, admin, system user, settings)
|
# Always run bootstrap (fee types, custom fields, roles, admin, system user, settings)
|
||||||
Code.eval_file("priv/repo/seeds_bootstrap.exs")
|
Code.eval_file("priv/repo/seeds_bootstrap.exs")
|
||||||
|
|
||||||
|
|
@ -25,6 +32,7 @@ try do
|
||||||
end
|
end
|
||||||
|
|
||||||
IO.puts("✅ All seeds completed.")
|
IO.puts("✅ All seeds completed.")
|
||||||
after
|
after
|
||||||
Code.compiler_options(prev)
|
Code.compiler_options(prev)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue