diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c23c01..681169f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.1.0] - 2026-03-13 + +### Added +- **Browser timezone for datetime display** – Date/time values (e.g. join request submitted at, approved at, rejected at) are shown in the user’s local timezone. +- **Registration toggle** – New global setting to disable direct registration (`/register`). When disabled, visitors are redirected to sign-in and the register link is hidden; join form remains available. +- **Configurable SMTP in global settings** – SMTP host, port, user, password, and TLS options configurable via Admin → Global Settings. Test-email action to verify delivery. Join confirmation and other transactional emails use this configuration. +- **Theme and language selector on unauthenticated pages** – Sign-in and join pages now offer theme (light/dark) and locale (e.g. German/English) controls in the header. +- **Duplicate-email handling for join form** – If an applicant’s email is already a member or already has a pending join request, the system sends a clarifying email (already-member or already-pending) and shows the same success message (anti-enumeration). +- **Reviewed-by display for join requests** – Approval UI shows who reviewed a request via a dedicated display field, without loading the User record. +- **Improved field order and seeds for join request approval** – Approval screen field order improved; seed data updated for join-form and approval flows. +- **Tests for SMTP mailer configuration** – Tests for SMTP config and for join confirmation email delivery failure (domain and LiveView). + +### Changed +- **SMTP settings layout** – SMTP options reordered and grouped in global settings for clearer configuration. +- **Join confirmation mail** – Uses configurable SMTP from settings; on delivery failure the join form shows an error and no success message. +- **i18n** – Gettext catalogs updated for new and changed strings. + +### Fixed +- **Login page translation** – Corrected translation/locale handling on the sign-in page. + +--- + +## [1.0.0] and earlier + ### Added - **Roles and Permissions System (RBAC)** - Complete implementation (#345, 2026-01-08) - Four hardcoded permission sets: `own_data`, `read_only`, `normal_user`, `admin` diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md index 0cb8d65..8d53484 100644 --- a/CODE_GUIDELINES.md +++ b/CODE_GUIDELINES.md @@ -90,6 +90,8 @@ lib/ │ ├── custom_field.ex # Custom field (definition) resource │ ├── custom_field_value.ex # Custom field value resource │ ├── setting.ex # Global settings (singleton resource; incl. join form config) +│ ├── settings_cache.ex # Process cache for get_settings (TTL; invalidate on update; not started in test) +│ ├── join_notifier.ex # Behaviour for join emails (confirmation, already member, already pending) │ ├── setting/ # Setting changes (NormalizeJoinFormSettings, etc.) │ ├── group.ex # Group resource │ ├── member_group.ex # MemberGroup join table resource @@ -1275,6 +1277,8 @@ mix hex.outdated - SMTP can be configured via **ENV variables** (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_PASSWORD_FILE`, `SMTP_SSL`) or via **Admin Settings** (database: `smtp_host`, `smtp_port`, `smtp_username`, `smtp_password`, `smtp_ssl`). ENV takes priority (same pattern as OIDC/Vereinfacht). - **Sensitive settings in DB:** `smtp_password` and `oidc_client_secret` are excluded from the default read of the Setting resource; they are loaded only via explicit select when needed (e.g. `Mv.Config.smtp_password/0`, `Mv.Config.oidc_client_secret/0`). This avoids exposing secrets through `get_settings()`. +- **Settings cache:** `Mv.Membership.get_settings/0` uses `Mv.Membership.SettingsCache` when the cache process is running (not in test). Cache has a short TTL and is invalidated on every settings update. This avoids repeated DB reads on hot paths (e.g. `RegistrationEnabled` validation, `Layouts.public_page`). In test, the cache is not started so all callers use `get_settings_uncached/0` in the test process (Ecto Sandbox). +- **Join emails (domain → web):** The domain calls `Mv.Membership.JoinNotifier` (config `:join_notifier`, default `MvWeb.JoinNotifierImpl`) for sending join confirmation, already-member, and already-pending emails. This keeps the domain independent of the web layer; tests can override the notifier. - Sender identity is also configurable via ENV (`MAIL_FROM_NAME`, `MAIL_FROM_EMAIL`) or Settings (`smtp_from_name`, `smtp_from_email`). - `SMTP_PASSWORD_FILE`: path to a file containing the password (Docker Secrets / Kubernetes secrets pattern); overridden by `SMTP_PASSWORD` when both are set. - `SMTP_SSL` values: `tls` (default, port 587), `ssl` (port 465), `none` (port 25). @@ -1290,6 +1294,10 @@ mix hex.outdated - `SendPasswordResetEmail` and `SendNewUserConfirmationEmail` use `Mv.Mailer.deliver/1` (not `deliver!/1`). Errors are logged via `Logger.error` and not re-raised so they never crash the caller process. +**Join confirmation email:** + +- Join emails are sent via `Mv.Membership.JoinNotifier` (default impl: `MvWeb.JoinNotifierImpl` calling `JoinConfirmationEmail`, etc.). `MvWeb.Emails.JoinConfirmationEmail` uses `Mailer.deliver(email, Mailer.smtp_config())` so it uses the same SMTP configuration as the test mail (Settings or boot ENV). On delivery failure, `Mv.Membership.submit_join_request/2` returns `{:error, :email_delivery_failed}` (and logs via `Logger.error`); the JoinLive shows an error message and no success UI. + **Unified layout (transactional emails):** - All transactional emails (join confirmation, user confirmation, password reset) use the same layout: `MvWeb.EmailLayoutView` (layout) and `MvWeb.EmailsView` (body templates). diff --git a/DESIGN_GUIDELINES.md b/DESIGN_GUIDELINES.md index 92f7a90..0ad562e 100644 --- a/DESIGN_GUIDELINES.md +++ b/DESIGN_GUIDELINES.md @@ -76,6 +76,21 @@ For LiveViews that render an edit or new form (e.g. member, group, role, user, c If the `<.header>` is outside the `<.form>`, the submit button must reference the form via the `form` attribute (e.g. `form="user-form"`). +### 2.3 Public / unauthenticated pages (Join, Sign-in, Join Confirm) + +Pages that do not require authentication (e.g. `/join`, `/sign-in`, `/confirm_join/:token`) use a unified layout via the **`Layouts.public_page`** component: + +- **Component:** `Layouts.public_page` renders: + - **Header:** Logo + "Mitgliederverwaltung" (left) | Club name centered via absolute positioning | Language selector + theme swap (sun/moon, DaisyUI swap with rotate) (right) + - Main content slot, Flash group. No sidebar, no authenticated-layout logic. +- **Content:** DaisyUI **hero** section (`hero`, `hero-content`) for the main message or form, so all public pages share the same visual structure. The hero is constrained in width (`max-w-4xl mx-auto`) and content is left-aligned (`hero-content flex-col items-start text-left`). +- **Locale handling:** The language selector uses `Gettext.get_locale(MvWeb.Gettext)` (backend-specific) to correctly reflect the active locale. `SignInLive` sets both `Gettext.put_locale(MvWeb.Gettext, locale)` and `Gettext.put_locale(locale)` to keep global and backend locales in sync. +- **Translations for AshAuthentication components:** AshAuthentication’s `_gettext` mechanism translates button labels (e.g. “Sign in” → “Anmelden”, “Register” → “Registrieren”) at runtime via `gettext_fn: {MvWeb.Gettext, "auth"}`. Components that do NOT use `_gettext` (e.g. `HorizontalRule`) receive static German overrides via **`MvWeb.AuthOverridesDE`**, which is prepended to the overrides list in `SignInLive` when the locale is `"de"`. +- **Implementation:** + - **Sign-in** (`SignInLive`): Uses `use Phoenix.LiveView` (not `use MvWeb, :live_view`) so AshAuthentication’s sign_in_route live_session on_mount chain is not mixed with LiveHelpers hooks. Renders `` with the SignIn component inside a hero. Displays a locale-aware `

` 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 `` with a hero for the form. + - **Join Confirm** (controller): Uses `JoinConfirmHTML` with a template that wraps content in `` and a hero block for the result, so the confirm page shares the same header and chrome as Join and Sign-in. + ## 3) Typography (system) Use these standard roles: @@ -83,16 +98,18 @@ Use these standard roles: | Role | Use | Class | |---|---|---| | Page title (H1) | main page title | `text-xl font-semibold leading-8` | -| Subtitle | helper under title | `text-sm text-base-content/70` | +| Subtitle | helper under title | `text-sm text-base-content/85` | | Section title (H2) | section headings | `text-lg font-semibold` | -| Helper text | under inputs | `text-sm text-base-content/70` | -| Fine print | small hints | `text-xs text-base-content/60` | -| Empty state | no data | `text-base-content/60 italic` | +| Helper text | under inputs | `text-sm text-base-content/85` | +| Fine print | small hints | `text-xs text-base-content/80` | +| Empty state | no data | `text-base-content/80 italic` | | Destructive text | danger | `text-error` | **MUST:** Page titles via `<.header>`. **MUST:** Section titles via `<.form_section title="…">` (for forms) or a consistent section wrapper (if you introduce a `<.card>` later). +**Form labels (WCAG 2.2 AA):** DaisyUI `.label` defaults to 60% opacity and fails contrast. We override it in `app.css` to 85% of `base-content` so labels stay slightly de‑emphasised vs body text but meet the 4.5:1 minimum. Use `class="label"` and `` as usual; no extra classes needed. + --- ## 4) States: Loading, Empty, Error (mandatory consistency) @@ -204,6 +221,11 @@ If these cannot be met, use `secondary`/`outline` instead of `ghost`. - **MUST:** Required fields are marked consistently (UI indicator + accessible text). - **SHOULD:** If required-ness is configurable via settings, display it consistently in the form. +### 6.4 Form layout (settings / long forms) +- **SHOULD:** On wide viewports, use a responsive grid so related fields share a row and reduce scrolling (e.g. `grid grid-cols-1 lg:grid-cols-2` or `lg:grid-cols-[2fr_5rem_1fr]` for mixed widths). +- **SHOULD:** Limit the main content width for readability (e.g. Settings page uses `max-w-4xl mx-auto px-4` around the content area below the header). +- **Example:** SMTP settings use three rows on large screens (Host, Port, TLS/SSL | Username, Password | Sender email, Sender name) without subsection labels. + --- ## 7) Lists, Search & Filters (mandatory UX consistency) diff --git a/Justfile b/Justfile index f3ad5a3..d2c51e5 100644 --- a/Justfile +++ b/Justfile @@ -10,6 +10,7 @@ install-dependencies: mix deps.get migrate-database: + mix compile mix ash.setup reset-database: diff --git a/assets/css/app.css b/assets/css/app.css index e3c6e83..d7f873c 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -154,6 +154,14 @@ background-color: var(--color-base-100); } +/* WCAG 2.2 AA (4.5:1 for normal text): Form labels. DaisyUI .label uses 60% opacity, + which fails contrast. Override to 85% of base-content so labels stay slightly + de‑emphasised vs body text but meet the minimum ratio. Match .label directly + so the override applies even when data-theme is not yet set (e.g. initial load). */ +.label { + color: color-mix(in oklab, var(--color-base-content) 85%, transparent); +} + /* WCAG 2.2 AA (4.5:1 for normal text): Badge text must contrast with badge background. Theme tokens *-content are often too light on * backgrounds in light theme, and badge-soft uses variant as text on a light tint (low contrast). We override diff --git a/assets/js/app.js b/assets/js/app.js index ee423eb..87f2c25 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -25,6 +25,14 @@ import Sortable from "../vendor/sortable" let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") +function getBrowserTimezone() { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || null + } catch (_e) { + return null + } +} + // Hooks for LiveView components let Hooks = {} @@ -312,7 +320,10 @@ Hooks.SidebarState = { let liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 2500, - params: {_csrf_token: csrfToken}, + params: { + _csrf_token: csrfToken, + timezone: getBrowserTimezone() + }, hooks: Hooks }) diff --git a/config/config.exs b/config/config.exs index 35e4160..7bb4f61 100644 --- a/config/config.exs +++ b/config/config.exs @@ -46,6 +46,9 @@ config :spark, ] ] +# IANA timezone database for DateTime.shift_zone (browser timezone display) +config :elixir, :time_zone_database, Tz.TimeZoneDatabase + config :mv, ecto_repos: [Mv.Repo], generators: [timestamp_type: :utc_datetime], @@ -104,6 +107,9 @@ config :mv, :mail_from, {"Mila", "noreply@example.com"} # Join form rate limiting (Hammer). scale_ms: window in ms, limit: max submits per window per IP. config :mv, :join_rate_limit, scale_ms: 60_000, limit: 10 +# Join emails: notifier implementation (domain → web abstraction). Override in test to inject a mock. +config :mv, :join_notifier, MvWeb.JoinNotifierImpl + # Configure esbuild (the version is required) config :esbuild, version: "0.17.11", diff --git a/config/test.exs b/config/test.exs index 84ccd70..ef54982 100644 --- a/config/test.exs +++ b/config/test.exs @@ -58,3 +58,7 @@ config :mv, :sql_sandbox, true # Join form rate limit: low limit so tests can trigger rate limiting (e.g. 2 per minute) config :mv, :join_rate_limit, scale_ms: 60_000, limit: 2 + +# Ash: silence "after_transaction hooks in surrounding transaction" warning when using +# Ecto sandbox (tests run in a transaction; create_member after_transaction is expected). +config :ash, warn_on_transaction_hooks?: false diff --git a/docs/development-progress-log.md b/docs/development-progress-log.md index a6297ba..6d8e523 100644 --- a/docs/development-progress-log.md +++ b/docs/development-progress-log.md @@ -806,7 +806,7 @@ end - **Senders migrated:** `SendNewUserConfirmationEmail`, `SendPasswordResetEmail` use layout + `Mv.Mailer.mail_from/0`. - **Cleanup:** Mix task `mix join_requests.cleanup_expired` hard-deletes JoinRequests in `pending_confirmation` with expired `confirmation_token_expires_at` (authorize?: false). For cron/Oban. - **Gettext:** New email strings in default domain; German translations in de/LC_MESSAGES/default.po; English msgstr filled for email-related strings. -- **PR review follow-ups (Join confirmation):** Join confirmation email uses `Mailer.deliver/1` and returns `{:ok, email}` \| `{:error, reason}`; domain logs delivery errors but still returns `{:ok, request}` so the user sees success. Comment in `submit_join_request/2` clarifies that the raw token is hashed by `JoinRequest.Changes.SetConfirmationToken`. Cleanup task uses `Ash.bulk_destroy` and logs partial errors without halting. Layout uses assigns `app_name` and `locale` (from config/Gettext) instead of hardcoded "Mila" and `lang="de"`. Production `runtime.exs` sets `:mail_from` from ENV (`MAIL_FROM_NAME`, `MAIL_FROM_EMAIL`). Layout reference unified to `"layout.html"`; redundant `put_layout` removed from senders. +- **PR review follow-ups (Join confirmation):** Join confirmation email uses `Mailer.deliver/2` with `Mailer.smtp_config/0` (same config as test mail). On delivery failure the domain returns `{:error, :email_delivery_failed}` (logged via `Logger.error`), and the JoinLive shows an error message (no success UI). Comment in `submit_join_request/2` clarifies that the raw token is hashed by `JoinRequest.Changes.SetConfirmationToken`. Cleanup task uses `Ash.bulk_destroy` and logs partial errors without halting. Layout uses assigns `app_name` and `locale` (from config/Gettext) instead of hardcoded "Mila" and `lang="de"`. Production `runtime.exs` sets `:mail_from` from ENV (`MAIL_FROM_NAME`, `MAIL_FROM_EMAIL`). Layout reference unified to `"layout.html"`; redundant `put_layout` removed from senders. - Tests: `join_request_test.exs`, `join_request_submit_email_test.exs`, `join_confirm_controller_test.exs` – all pass. **Subtask 3 – Admin: Join form settings (done):** diff --git a/docs/feature-roadmap.md b/docs/feature-roadmap.md index 03f1cce..6383660 100644 --- a/docs/feature-roadmap.md +++ b/docs/feature-roadmap.md @@ -36,10 +36,10 @@ **Closed Issues:** - ✅ [#171](https://git.local-it.org/local-it/mitgliederverwaltung/issues/171) - OIDC handling and linking (closed 2025-11-13) +- ✅ [#146](https://git.local-it.org/local-it/mitgliederverwaltung/issues/146) - Translate "or" in the login screen — fixed via `MvWeb.AuthOverridesDE` locale-specific module (2026-03-13) +- ✅ [#144](https://git.local-it.org/local-it/mitgliederverwaltung/issues/144) - Add language switch dropdown to login screen — fixed locale selector bug with `Gettext.get_locale(MvWeb.Gettext)` (2026-03-13) -**Open Issues:** -- [#146](https://git.local-it.org/local-it/mitgliederverwaltung/issues/146) - Translate "or" in the login screen (Low) -- [#144](https://git.local-it.org/local-it/mitgliederverwaltung/issues/144) - Add language switch dropdown to login screen (Low) +**Open Issues:** (none remaining for Authentication UI) **Current State:** - ✅ **Role-based access control (RBAC)** - Implemented (2026-01-08, PR #346, closes #345) diff --git a/docs/onboarding-join-concept.md b/docs/onboarding-join-concept.md index 8083a7b..8e6c615 100644 --- a/docs/onboarding-join-concept.md +++ b/docs/onboarding-join-concept.md @@ -93,6 +93,7 @@ - **Placement:** Own section **"Onboarding / Join"** in global settings, **above** "Custom fields", **below** "Vereinsdaten" (club data). - **Join form enabled:** Checkbox (e.g. `join_form_enabled`). When set, the public `/join` page is active and the following config applies. +- **Copyable join link:** When the join form is enabled, a copyable full URL to the `/join` page is shown below the checkbox (above the field list), with a short hint so admins can share it with applicants. - **Field selection:** From **all existing** member fields (from `Mv.Constants.member_fields()`) and **custom fields**, the admin selects which fields appear on the join form. Stored as a list/set of field identifiers (no separate table); display in settings as a simple list, e.g. **badges with X to remove** (similar to the groups overview). Adding fields: e.g. dropdown or modal to pick from remaining fields. Detailed UX for this subsection is to be specified in a **separate subtask**. - **Technically required fields:** The only field that must always be required for the join flow is **email**. All other fields can be optional or marked as required per admin choice; implementation should support a "required" flag per selected join-form field. - **Other:** Which entry paths are enabled, approval workflow (who can approve) – to be detailed in Step 2 and later specs. @@ -115,7 +116,7 @@ Implementation spec for Subtask 5. #### Route and pages - **List:** **`/join_requests`** – list of join requests. Filter by status (default or primary view: status `submitted`); optional view for "all" or "approved/rejected" for audit. -- **Detail:** **`/join_requests/:id`** – single join request with all data (typed fields + `form_data`), actions Approve / Reject. +- **Detail:** **`/join_requests/:id`** – single join request. **Two blocks:** (1) **Applicant data** – all form fields (typed + `form_data`) merged and shown in join-form order; (2) **Status and review** – submitted_at, status, and when decided: approved_at/rejected_at, reviewed by. Actions Approve / Reject when status is `submitted`. #### Backend (JoinRequest) @@ -195,7 +196,7 @@ Implementation spec for Subtask 5. - **Pre-confirmation store:** **DB only.** Same JoinRequest resource; no ETS, no stateless token. Confirmation token stored as **hash** in DB; raw token only in email link. **24h** retention for `pending_confirmation`; **hard-delete** of expired records via scheduled job (e.g. Oban cron). - **Confirmation route:** **`/confirm_join/:token`** so existing `starts_with?(path, "/confirm")` covers it. - **Public path for `/join`:** **Add `/join` explicitly** to the page-permission plug’s `public_path?/1` (e.g. in `CheckPagePermission`) so unauthenticated users can reach the join page. -- **JoinRequest schema:** Status `pending_confirmation` | `submitted` | `approved` | `rejected`. Typed: **email** (required), **first_name**, **last_name** (optional). **form_data** (jsonb) + **schema_version** for remaining form fields. **confirmation_token_hash**, **confirmation_token_expires_at**; **submitted_at**, **approved_at**, **rejected_at**, **reviewed_by_user_id** for audit. Idempotent confirm (unique constraint on token hash or update only when status is `pending_confirmation`). +- **JoinRequest schema:** Status `pending_confirmation` | `submitted` | `approved` | `rejected`. Typed: **email** (required), **first_name**, **last_name** (optional). **form_data** (jsonb) + **schema_version** for remaining form fields. **confirmation_token_hash**, **confirmation_token_expires_at**; **submitted_at**, **approved_at**, **rejected_at**, **reviewed_by_user_id**, **reviewed_by_display** (denormalized reviewer email for "Geprüft von" without loading User) for audit. Idempotent confirm (unique constraint on token hash or update only when status is `pending_confirmation`). - **Approval outcome:** Admin-configurable. Default: approval creates Member only (no User). Optional "create User on approval" is **left for later**. - **Rate limiting:** Honeypot + rate limiting from the start (e.g. Hammer.Plug). - **Settings:** Own section "Onboarding / Join" in global settings; `join_form_enabled` plus field selection; display as list/badges; detailed UX in a **separate subtask**. diff --git a/docs/settings-authentication-mockup.txt b/docs/settings-authentication-mockup.txt new file mode 100644 index 0000000..00f64c4 --- /dev/null +++ b/docs/settings-authentication-mockup.txt @@ -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] | ++------------------------------------------------------------------+ diff --git a/docs/smtp-configuration-concept.md b/docs/smtp-configuration-concept.md index 30fd7de..8832b5e 100644 --- a/docs/smtp-configuration-concept.md +++ b/docs/smtp-configuration-concept.md @@ -44,6 +44,8 @@ When an ENV variable is set, the corresponding Settings field is read-only in th **Important:** On most SMTP servers (e.g. Postfix with strict relay policies) the sender email (`smtp_from_email`) must be the same address as `smtp_username` or an alias that is owned by that account. +**Settings UI:** The form uses three rows on wide viewports: host, port, TLS/SSL | username, password | sender email, sender name. Content width is limited by the global settings wrapper (see `DESIGN_GUIDELINES.md` §6.4). + --- ## 5. Password from File @@ -82,13 +84,19 @@ Provided by `Mv.Config.mail_from_name/0` and `Mv.Config.mail_from_email/0`. --- -## 9. AshAuthentication Senders +## 9. Join Confirmation Email + +`MvWeb.Emails.JoinConfirmationEmail` uses the same SMTP configuration as the test email: `Mailer.deliver(email, Mailer.smtp_config())`. This ensures Settings-based SMTP is used when not configured via ENV at boot. On delivery failure the domain returns `{:error, :email_delivery_failed}` (and logs via `Logger.error`); the JoinLive shows an error message and no success UI. + +--- + +## 10. AshAuthentication Senders Both `SendPasswordResetEmail` and `SendNewUserConfirmationEmail` use `Mv.Mailer.deliver/1` (not `deliver!/1`). Delivery failures are logged (`Logger.error`) and not re-raised, so they never crash the caller process. AshAuthentication ignores the return value of `send/3`. --- -## 10. TLS / SSL in OTP 27 +## 11. TLS / SSL in OTP 27 OTP 26+ enforces `verify_peer` by default, which fails for self-signed or internal SMTP server certificates. @@ -101,7 +109,7 @@ Both `tls_options` (STARTTLS, port 587) and `sockopts` (direct SSL, port 465) us --- -## 11. Summary Checklist +## 12. Summary Checklist - [x] ENV: `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_PASSWORD_FILE`, `SMTP_SSL`. - [x] ENV: `MAIL_FROM_NAME`, `MAIL_FROM_EMAIL` for sender identity. @@ -112,13 +120,14 @@ Both `tls_options` (STARTTLS, port 587) and `sockopts` (direct SSL, port 465) us - [x] TLS certificate validation relaxed for OTP 27 (tls_options + sockopts). - [x] Prod warning: clear message in Settings when SMTP is not configured. - [x] Test email: form with recipient field, translatable content, classified success/error messages. +- [x] Join confirmation email: uses `Mailer.smtp_config/0` (same as test mail); on failure returns `{:error, :email_delivery_failed}`, error shown in JoinLive, logged for admin. - [x] AshAuthentication senders: graceful error handling (no crash on delivery failure). - [x] Gettext for all new UI strings, translated to German. - [x] Docs and code guidelines updated. --- -## 12. Follow-up / Future Work +## 13. Follow-up / Future Work - **SMTP password at-rest encryption:** The `smtp_password` attribute is currently stored in plaintext in the `settings` table. It is excluded from default reads (same pattern as `oidc_client_secret`); both are read only via explicit select when needed. For production systems at-rest encryption (e.g. with [Cloak](https://hexdocs.pm/cloak)) should be considered and tracked as a follow-up issue. - **Error classification:** SMTP error categorization currently uses substring matching on server messages (e.g. "535", "authentication"). A more robust approach would be to pattern-match on `gen_smtp` error tuples first where possible, and fall back to string analysis only when needed. Server wording varies; consider extending patterns as new providers are used. diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex index 6b9cd1e..29a2d4b 100644 --- a/lib/accounts/user.ex +++ b/lib/accounts/user.ex @@ -405,6 +405,10 @@ defmodule Mv.Accounts.User do where: [action_is([:register_with_password, :admin_set_password])], message: "must have length of at least 8" + # Block direct registration when disabled in global settings + validate {Mv.Accounts.User.Validations.RegistrationEnabled, []}, + where: [action_is(:register_with_password)] + # Email uniqueness check for all actions that change the email attribute # Validates that user email is not already used by another (unlinked) member validate Mv.Accounts.User.Validations.EmailNotUsedByOtherMember diff --git a/lib/accounts/user/validations/registration_enabled.ex b/lib/accounts/user/validations/registration_enabled.ex new file mode 100644 index 0000000..f2342b7 --- /dev/null +++ b/lib/accounts/user/validations/registration_enabled.ex @@ -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 diff --git a/lib/membership/join_notifier.ex b/lib/membership/join_notifier.ex new file mode 100644 index 0000000..daec4c1 --- /dev/null +++ b/lib/membership/join_notifier.ex @@ -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 diff --git a/lib/membership/join_request.ex b/lib/membership/join_request.ex index 05a9e8d..94907e2 100644 --- a/lib/membership/join_request.ex +++ b/lib/membership/join_request.ex @@ -77,6 +77,17 @@ defmodule Mv.Membership.JoinRequest do change Mv.Membership.JoinRequest.Changes.RejectRequest end + + # Internal: resend confirmation (new token) when user submits form again with same email. + # Called from domain with authorize?: false; not exposed to public. + update :regenerate_confirmation_token do + description "Set new confirmation token and expiry (resend flow)" + require_atomic? false + + argument :confirmation_token, :string, allow_nil?: false + + change Mv.Membership.JoinRequest.Changes.RegenerateConfirmationToken + end end policies do @@ -175,6 +186,11 @@ defmodule Mv.Membership.JoinRequest do attribute :approved_at, :utc_datetime_usec attribute :rejected_at, :utc_datetime_usec attribute :reviewed_by_user_id, :uuid + + attribute :reviewed_by_display, :string do + description "Denormalized reviewer display (e.g. email) for UI without loading User" + end + attribute :source, :string create_timestamp :inserted_at diff --git a/lib/membership/join_request/changes/approve_request.ex b/lib/membership/join_request/changes/approve_request.ex index 24716f6..b86ca5d 100644 --- a/lib/membership/join_request/changes/approve_request.ex +++ b/lib/membership/join_request/changes/approve_request.ex @@ -16,11 +16,13 @@ defmodule Mv.Membership.JoinRequest.Changes.ApproveRequest do if current_status == :submitted do reviewed_by_id = Helpers.actor_id(context.actor) + reviewed_by_display = Helpers.actor_email(context.actor) changeset |> Ash.Changeset.force_change_attribute(:status, :approved) |> Ash.Changeset.force_change_attribute(:approved_at, DateTime.utc_now()) |> Ash.Changeset.force_change_attribute(:reviewed_by_user_id, reviewed_by_id) + |> Ash.Changeset.force_change_attribute(:reviewed_by_display, reviewed_by_display) else Ash.Changeset.add_error(changeset, field: :status, diff --git a/lib/membership/join_request/changes/helpers.ex b/lib/membership/join_request/changes/helpers.ex index ee09b75..9bb0697 100644 --- a/lib/membership/join_request/changes/helpers.ex +++ b/lib/membership/join_request/changes/helpers.ex @@ -16,4 +16,24 @@ defmodule Mv.Membership.JoinRequest.Changes.Helpers do end def actor_id(_), do: nil + + @doc """ + Extracts the actor's email for display (e.g. reviewed_by_display). + + Supports both atom and string keys for compatibility with different actor representations. + """ + @spec actor_email(term()) :: String.t() | nil + def actor_email(nil), do: nil + + def actor_email(actor) when is_map(actor) do + raw = Map.get(actor, :email) || Map.get(actor, "email") + if is_nil(raw), do: nil, else: actor_email_string(raw) + end + + def actor_email(_), do: nil + + defp actor_email_string(raw) do + s = raw |> to_string() |> String.trim() + if s == "", do: nil, else: s + end end diff --git a/lib/membership/join_request/changes/regenerate_confirmation_token.ex b/lib/membership/join_request/changes/regenerate_confirmation_token.ex new file mode 100644 index 0000000..c8055d2 --- /dev/null +++ b/lib/membership/join_request/changes/regenerate_confirmation_token.ex @@ -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 diff --git a/lib/membership/join_request/changes/reject_request.ex b/lib/membership/join_request/changes/reject_request.ex index 2c33a77..1b9fe1a 100644 --- a/lib/membership/join_request/changes/reject_request.ex +++ b/lib/membership/join_request/changes/reject_request.ex @@ -15,11 +15,13 @@ defmodule Mv.Membership.JoinRequest.Changes.RejectRequest do if current_status == :submitted do reviewed_by_id = Helpers.actor_id(context.actor) + reviewed_by_display = Helpers.actor_email(context.actor) changeset |> Ash.Changeset.force_change_attribute(:status, :rejected) |> Ash.Changeset.force_change_attribute(:rejected_at, DateTime.utc_now()) |> Ash.Changeset.force_change_attribute(:reviewed_by_user_id, reviewed_by_id) + |> Ash.Changeset.force_change_attribute(:reviewed_by_display, reviewed_by_display) else Ash.Changeset.add_error(changeset, field: :status, diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex index 2f18f90..7fa35dc 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -29,8 +29,10 @@ defmodule Mv.Membership do require Ash.Query import Ash.Expr alias Ash.Error.Query.NotFound, as: NotFoundError + alias Mv.Helpers.SystemActor alias Mv.Membership.JoinRequest - alias MvWeb.Emails.JoinConfirmationEmail + alias Mv.Membership.Member + alias Mv.Membership.SettingsCache require Logger admin do @@ -114,10 +116,16 @@ defmodule Mv.Membership do """ def get_settings do - # Try to get the first (and only) settings record + case Process.whereis(SettingsCache) do + nil -> get_settings_uncached() + _pid -> SettingsCache.get() + end + end + + @doc false + def get_settings_uncached do case Ash.read_one(Mv.Membership.Setting, domain: __MODULE__) do {:ok, nil} -> - # No settings exist - create as fallback (should normally be created via seed script) default_club_name = System.get_env("ASSOCIATION_NAME") || "Club Name" Mv.Membership.Setting @@ -158,9 +166,16 @@ defmodule Mv.Membership do """ def update_settings(settings, attrs) do - settings - |> Ash.Changeset.for_update(:update, attrs) - |> Ash.update(domain: __MODULE__) + case settings + |> Ash.Changeset.for_update(:update, attrs) + |> Ash.update(domain: __MODULE__) do + {:ok, _updated} = result -> + SettingsCache.invalidate() + result + + error -> + error + end end @doc """ @@ -224,11 +239,18 @@ defmodule Mv.Membership do """ def update_member_field_visibility(settings, visibility_config) do - settings - |> Ash.Changeset.for_update(:update_member_field_visibility, %{ - member_field_visibility: visibility_config - }) - |> Ash.update(domain: __MODULE__) + case settings + |> Ash.Changeset.for_update(:update_member_field_visibility, %{ + member_field_visibility: visibility_config + }) + |> Ash.update(domain: __MODULE__) do + {:ok, _} = result -> + SettingsCache.invalidate() + result + + error -> + error + end end @doc """ @@ -261,12 +283,19 @@ defmodule Mv.Membership do field: field, show_in_overview: show_in_overview ) do - settings - |> Ash.Changeset.new() - |> Ash.Changeset.set_argument(:field, field) - |> Ash.Changeset.set_argument(:show_in_overview, show_in_overview) - |> Ash.Changeset.for_update(:update_single_member_field_visibility, %{}) - |> Ash.update(domain: __MODULE__) + case settings + |> Ash.Changeset.new() + |> Ash.Changeset.set_argument(:field, field) + |> Ash.Changeset.set_argument(:show_in_overview, show_in_overview) + |> Ash.Changeset.for_update(:update_single_member_field_visibility, %{}) + |> Ash.update(domain: __MODULE__) do + {:ok, _} = result -> + SettingsCache.invalidate() + result + + error -> + error + end end @doc """ @@ -300,13 +329,20 @@ defmodule Mv.Membership do show_in_overview: show_in_overview, required: required ) do - settings - |> Ash.Changeset.new() - |> Ash.Changeset.set_argument(:field, field) - |> Ash.Changeset.set_argument(:show_in_overview, show_in_overview) - |> Ash.Changeset.set_argument(:required, required) - |> Ash.Changeset.for_update(:update_single_member_field, %{}) - |> Ash.update(domain: __MODULE__) + case settings + |> Ash.Changeset.new() + |> Ash.Changeset.set_argument(:field, field) + |> Ash.Changeset.set_argument(:show_in_overview, show_in_overview) + |> Ash.Changeset.set_argument(:required, required) + |> Ash.Changeset.for_update(:update_single_member_field, %{}) + |> Ash.update(domain: __MODULE__) do + {:ok, _} = result -> + SettingsCache.invalidate() + result + + error -> + error + end end @doc """ @@ -364,15 +400,131 @@ defmodule Mv.Membership do - `:actor` - Must be nil for public submit (policy allows only unauthenticated). ## Returns - - `{:ok, request}` - Created JoinRequest in status pending_confirmation + - `{:ok, request}` - Created JoinRequest in status pending_confirmation, email sent + - `{:ok, :notified_already_member}` - Email already a member; notice sent by email only (no request created) + - `{:ok, :notified_already_pending}` - Email already has pending/submitted request; notice or resend sent by email only + - `{:error, :email_delivery_failed}` - Request created but confirmation email could not be sent (logged) - `{:error, error}` - Validation or authorization error """ def submit_join_request(attrs, opts \\ []) do actor = Keyword.get(opts, :actor) - token = Map.get(attrs, :confirmation_token) || generate_confirmation_token() + email = normalize_submit_email(attrs) - # Raw token is passed to the submit action; JoinRequest.Changes.SetConfirmationToken - # hashes it before persist. Only the hash is stored; the raw token is sent in the email link. + pending = + if email != nil and email != "", do: pending_join_request_with_email(email), else: nil + + cond do + email != nil and email != "" and member_exists_with_email?(email) -> + send_already_member_and_return(email) + + pending != nil -> + handle_already_pending(email, pending) + + true -> + do_create_join_request(attrs, actor) + end + end + + defp normalize_submit_email(attrs) do + raw = attrs["email"] || attrs[:email] + if is_binary(raw), do: String.trim(raw), else: nil + end + + defp member_exists_with_email?(email) when is_binary(email) do + system_actor = SystemActor.get_system_actor() + opts = [actor: system_actor, domain: __MODULE__] + + case Ash.get(Member, %{email: email}, opts) do + {:ok, _member} -> true + _ -> false + end + end + + defp member_exists_with_email?(_), do: false + + defp pending_join_request_with_email(email) when is_binary(email) do + system_actor = SystemActor.get_system_actor() + + query = + JoinRequest + |> Ash.Query.filter(expr(email == ^email and status in [:pending_confirmation, :submitted])) + |> Ash.Query.sort(inserted_at: :desc) + |> Ash.Query.limit(1) + + case Ash.read_one(query, actor: system_actor, domain: __MODULE__) do + {:ok, request} -> request + _ -> nil + end + end + + defp pending_join_request_with_email(_), do: nil + + defp join_notifier do + Application.get_env(:mv, :join_notifier, MvWeb.JoinNotifierImpl) + end + + defp send_already_member_and_return(email) do + case join_notifier().send_already_member(email) do + {:ok, _} -> + :ok + + {:error, reason} -> + Logger.error("Join already-member email failed for #{email}: #{inspect(reason)}") + end + + # Delay is applied by the caller (e.g. JoinLive) to avoid blocking the process. + {:ok, :notified_already_member} + end + + defp handle_already_pending(email, existing) do + if existing.status == :pending_confirmation do + resend_confirmation_to_pending(email, existing) + else + send_already_pending_and_return(email) + end + end + + defp resend_confirmation_to_pending(email, request) do + new_token = generate_confirmation_token() + + case request + |> Ash.Changeset.for_update(:regenerate_confirmation_token, %{ + confirmation_token: new_token + }) + |> Ash.update(domain: __MODULE__, authorize?: false) do + {:ok, _updated} -> + case join_notifier().send_confirmation(email, new_token, resend: true) do + {:ok, _} -> + :ok + + {:error, reason} -> + Logger.error("Join resend confirmation email failed for #{email}: #{inspect(reason)}") + end + + # Delay is applied by the caller (e.g. JoinLive) to avoid blocking the process. + {:ok, :notified_already_pending} + + {:error, _} -> + # Fallback: do not create duplicate; send generic pending email + send_already_pending_and_return(email) + end + end + + defp send_already_pending_and_return(email) do + case join_notifier().send_already_pending(email) do + {:ok, _} -> + :ok + + {:error, reason} -> + Logger.error("Join already-pending email failed for #{email}: #{inspect(reason)}") + end + + # Delay is applied by the caller (e.g. JoinLive) to avoid blocking the process. + {:ok, :notified_already_pending} + end + + defp do_create_join_request(attrs, actor) do + token = Map.get(attrs, :confirmation_token) || generate_confirmation_token() attrs_with_token = Map.put(attrs, :confirmation_token, token) case Ash.create(JoinRequest, attrs_with_token, @@ -381,8 +533,9 @@ defmodule Mv.Membership do domain: __MODULE__ ) do {:ok, request} -> - case JoinConfirmationEmail.send(request.email, token) do + case join_notifier().send_confirmation(request.email, token, []) do {:ok, _email} -> + # Delay is applied by the caller (e.g. JoinLive) to avoid blocking the process. {:ok, request} {:error, reason} -> @@ -390,8 +543,7 @@ defmodule Mv.Membership do "Join confirmation email failed for #{request.email}: #{inspect(reason)}" ) - # Request was created; return success so the user sees the confirmation message - {:ok, request} + {:error, :email_delivery_failed} end error -> diff --git a/lib/membership/setting.ex b/lib/membership/setting.ex index ce63589..83c5c8b 100644 --- a/lib/membership/setting.ex +++ b/lib/membership/setting.ex @@ -15,6 +15,7 @@ defmodule Mv.Membership.Setting do (e.g., `%{"first_name" => true, "last_name" => true}`). Email is always required; other fields default to optional. - `include_joining_cycle` - Whether to include the joining cycle in membership fee generation (default: true) - `default_membership_fee_type_id` - Default membership fee type for new members (optional) + - `registration_enabled` - Whether direct registration via /register is allowed (default: true) - `join_form_enabled` - Whether the public /join page is active (default: false) - `join_form_field_ids` - Ordered list of field IDs shown on the join form. Each entry is either a member field name string (e.g. "email") or a custom field UUID. Email is always @@ -129,6 +130,7 @@ defmodule Mv.Membership.Setting do :smtp_ssl, :smtp_from_name, :smtp_from_email, + :registration_enabled, :join_form_enabled, :join_form_field_ids, :join_form_field_required @@ -165,6 +167,7 @@ defmodule Mv.Membership.Setting do :smtp_ssl, :smtp_from_name, :smtp_from_email, + :registration_enabled, :join_form_enabled, :join_form_field_ids, :join_form_field_required @@ -514,6 +517,15 @@ defmodule Mv.Membership.Setting do description "Email address for the transactional email sender. Must be owned by the SMTP user. Overrides MAIL_FROM_EMAIL env." end + # Authentication: direct registration toggle + attribute :registration_enabled, :boolean do + allow_nil? false + default true + public? true + + description "When true, users can register via /register; when false, only sign-in and join form remain available." + end + # Join form (Beitrittsformular) settings attribute :join_form_enabled, :boolean do allow_nil? false diff --git a/lib/membership/settings_cache.ex b/lib/membership/settings_cache.ex new file mode 100644 index 0000000..d8581d6 --- /dev/null +++ b/lib/membership/settings_cache.ex @@ -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 diff --git a/lib/mv/application.ex b/lib/mv/application.ex index 6b4a10b..1b6014e 100644 --- a/lib/mv/application.ex +++ b/lib/mv/application.ex @@ -6,6 +6,7 @@ defmodule Mv.Application do use Application alias Mv.Helpers.SystemActor + alias Mv.Membership.SettingsCache alias Mv.Repo alias Mv.Vereinfacht.SyncFlash alias MvWeb.Endpoint @@ -16,20 +17,28 @@ defmodule Mv.Application do def start(_type, _args) do SyncFlash.create_table!() - children = [ - Telemetry, - Repo, - {JoinRateLimit, [clean_period: :timer.minutes(1)]}, - {Task.Supervisor, name: Mv.TaskSupervisor}, - {DNSCluster, query: Application.get_env(:mv, :dns_cluster_query) || :ignore}, - {Phoenix.PubSub, name: Mv.PubSub}, - {AshAuthentication.Supervisor, otp_app: :my}, - SystemActor, - # Start a worker by calling: Mv.Worker.start_link(arg) - # {Mv.Worker, arg}, - # Start to serve requests, typically the last entry - Endpoint - ] + # SettingsCache not started in test so get_settings runs in the test process (Ecto Sandbox). + cache_children = + if Application.get_env(:mv, :environment) == :test, do: [], else: [SettingsCache] + + children = + [ + Telemetry, + Repo + ] ++ + cache_children ++ + [ + {JoinRateLimit, [clean_period: :timer.minutes(1)]}, + {Task.Supervisor, name: Mv.TaskSupervisor}, + {DNSCluster, query: Application.get_env(:mv, :dns_cluster_query) || :ignore}, + {Phoenix.PubSub, name: Mv.PubSub}, + {AshAuthentication.Supervisor, otp_app: :my}, + SystemActor, + # Start a worker by calling: Mv.Worker.start_link(arg) + # {Mv.Worker, arg}, + # Start to serve requests, typically the last entry + Endpoint + ] # See https://hexdocs.pm/elixir/Supervisor.html # for other strategies and supported options diff --git a/lib/mv_web/auth_overrides.ex b/lib/mv_web/auth_overrides.ex index 5cab4d2..3aab0ed 100644 --- a/lib/mv_web/auth_overrides.ex +++ b/lib/mv_web/auth_overrides.ex @@ -3,52 +3,70 @@ defmodule MvWeb.AuthOverrides do UI customizations for AshAuthentication Phoenix components. ## Overrides - - `SignIn` - Restricts form width to prevent full-width display - - `Banner` - Replaces default logo with "Mitgliederverwaltung" text - - `HorizontalRule` - Translates "or" text to German + - `SignIn` - Restricts form width and hides the library banner (title is rendered in SignInLive) + - `Banner` - Replaces default logo with text for reset/confirm pages + - `Flash` - Hides library flash (we use flash_group in root layout) ## Documentation For complete reference on available overrides, see: https://hexdocs.pm/ash_authentication_phoenix/ui-overrides.html """ use AshAuthentication.Phoenix.Overrides - use Gettext, backend: MvWeb.Gettext - # configure your UI overrides here - - # First argument to `override` is the component name you are overriding. - # The body contains any number of configurations you wish to override - # Below are some examples - - # For a complete reference, see https://hexdocs.pm/ash_authentication_phoenix/ui-overrides.html - - # override AshAuthentication.Phoenix.Components.Banner do - # set :image_url, "https://media.giphy.com/media/g7GKcSzwQfugw/giphy.gif" - # set :text_class, "bg-red-500" - # end - - # Avoid full-width for the Sign In Form + # Avoid full-width for the Sign In Form. + # Banner is hidden because SignInLive renders its own locale-aware title. override AshAuthentication.Phoenix.Components.SignIn do set :root_class, "md:min-w-md" + set :show_banner, false end - # Replace banner logo with text (no image in light or dark so link has discernible text) + # Replace banner logo with text for reset/confirm pages (no image so link has discernible text). override AshAuthentication.Phoenix.Components.Banner do set :text, "Mitgliederverwaltung" set :image_url, nil set :dark_image_url, nil end - # Translate the "or" in the horizontal rule (between password form and SSO). - # Uses auth domain so it respects the current locale (e.g. "oder" in German). - override AshAuthentication.Phoenix.Components.HorizontalRule do - set :text, dgettext("auth", "or") - end - - # Hide AshAuthentication's Flash component since we use flash_group in root layout - # This prevents duplicate flash messages + # Hide AshAuthentication's Flash component since we use flash_group in root layout. + # This prevents duplicate flash messages. override AshAuthentication.Phoenix.Components.Flash do set :message_class_info, "hidden" set :message_class_error, "hidden" end end + +defmodule MvWeb.AuthOverridesRegistrationDisabled do + @moduledoc """ + When direct registration is disabled in global settings, this override is + prepended in SignInLive so the Password component hides the "Need an account?" + toggle (register_toggle_text: nil disables the register link per library docs). + """ + use AshAuthentication.Phoenix.Overrides + + override AshAuthentication.Phoenix.Components.Password do + set :register_toggle_text, nil + end +end + +defmodule MvWeb.AuthOverridesDE do + @moduledoc """ + German locale-specific overrides for AshAuthentication Phoenix components. + + Prepended to the overrides list in SignInLive when the locale is "de". + Provides runtime-static German text for components that do not use + the `_gettext` mechanism (e.g. HorizontalRule renders its text directly), + and for submit buttons whose disable_text bypasses the POT extraction pipeline. + """ + use AshAuthentication.Phoenix.Overrides + + # HorizontalRule renders text without `_gettext`, so we need a static German string. + override AshAuthentication.Phoenix.Components.HorizontalRule do + set :text, "oder" + end + + # Registering ... disable-text is passed through _gettext but "Registering ..." + # has no dgettext source reference, so we supply the German string directly. + override AshAuthentication.Phoenix.Components.Password.RegisterForm do + set :disable_button_text, "Registrieren..." + end +end diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 11a60ef..8c58c32 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -1295,6 +1295,41 @@ defmodule MvWeb.CoreComponents do """ end + @doc """ + Renders a theme toggle using DaisyUI swap (sun/moon with rotate effect). + + Wired to the theme script in root layout: checkbox uses `data-theme-toggle`, + root script syncs checked state (checked = dark) and listens for `phx:set-theme`. + Use in public header or sidebar. Optional `class` is applied to the wrapper. + """ + attr :class, :string, default: nil, doc: "Optional extra classes for the swap wrapper" + + def theme_swap(assigns) do + assigns = assign(assigns, :wrapper_class, assigns[:class]) + + ~H""" +
+ +
+ """ + end + @doc """ Renders a [Heroicon](https://heroicons.com). diff --git a/lib/mv_web/components/layouts.ex b/lib/mv_web/components/layouts.ex index 2979eb4..5a96001 100644 --- a/lib/mv_web/components/layouts.ex +++ b/lib/mv_web/components/layouts.ex @@ -13,6 +13,98 @@ defmodule MvWeb.Layouts do embed_templates "layouts/*" + @doc """ + Builds the full browser tab title: "Mila", "Mila · Page", or "Mila · Page · Club". + Order is always: Mila · page title · club name. + Uses assigns[:club_name] and the short page label from assigns[:content_title] or + assigns[:page_title]. LiveViews should set content_title (same gettext as sidebar) + and then assign page_title to the result of this function so the client receives + the full title. + """ + def page_title_string(assigns) do + club = assigns[:club_name] + page = assigns[:content_title] || assigns[:page_title] + + parts = + [page, club] + |> Enum.filter(&(is_binary(&1) and String.trim(&1) != "")) + + if parts == [] do + "Mila" + else + "Mila · " <> Enum.join(parts, " · ") + end + end + + @doc """ + Assigns content_title (short label for heading; same gettext as sidebar) and + page_title (full browser tab title). Call from LiveView mount after club_name + is set (e.g. from on_mount). Returns the socket. + """ + def assign_page_title(socket, content_title) do + socket = assign(socket, :content_title, content_title) + assign(socket, :page_title, page_title_string(socket.assigns)) + end + + @doc """ + Renders the public (unauthenticated) page layout: header with logo + "Mitgliederverwaltung" left, + club name centered, language selector right; plus main content and flash group. Use for sign-in, join, and join-confirm pages so they + share the same chrome without the sidebar or authenticated layout logic. + + Pass optional `:club_name` from the parent (e.g. LiveView mount) to avoid a settings read in the component. + """ + attr :flash, :map, required: true, doc: "the map of flash messages" + + attr :club_name, :string, + default: nil, + doc: "optional; if set, avoids get_settings() in the component" + + slot :inner_block, required: true + + def public_page(assigns) do + club_name = + assigns[:club_name] || + case Mv.Membership.get_settings() do + {:ok, s} -> s.club_name || "Mitgliederverwaltung" + _ -> "Mitgliederverwaltung" + end + + assigns = assign(assigns, :club_name, club_name) + + ~H""" +
+
+ Mila Logo + Mitgliederverwaltung +
+ + {@club_name} + +
+
+ + +
+ <.theme_swap /> +
+
+
+
+ {render_slot(@inner_block)} +
+
+ <.flash_group flash={@flash} /> + """ + end + @doc """ Renders the app layout. Can be used with or without a current_user. When current_user is present, it will show the navigation bar. @@ -99,24 +191,30 @@ defmodule MvWeb.Layouts do <% else %> - -
- Mila Logo - + +
+
+ Mila Logo + Mitgliederverwaltung +
+ {@club_name} -
- - -
+
+
+ + +
+ <.theme_swap /> +
diff --git a/lib/mv_web/components/layouts/root.html.heex b/lib/mv_web/components/layouts/root.html.heex index e107d5b..5419b73 100644 --- a/lib/mv_web/components/layouts/root.html.heex +++ b/lib/mv_web/components/layouts/root.html.heex @@ -7,8 +7,8 @@ - <.live_title default="Mv" suffix=" · Phoenix Framework"> - {assigns[:page_title]} + <.live_title default="Mila"> + {page_title_string(assigns)}