Compare commits
6 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f8a3cc4c47 | |||
| c381b86b5e | |||
| 9b0f269ab6 | |||
| f353f1cbc0 | |||
| e8f27690a1 | |||
| e95c1d6254 |
34 changed files with 955 additions and 134 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
|
||||||
|
|
|
||||||
14
CHANGELOG.md
14
CHANGELOG.md
|
|
@ -5,7 +5,19 @@ 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
|
## [1.1.0] - 2026-03-13
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -130,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
|
||||||
|
|
@ -282,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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -113,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() {
|
||||||
|
|
|
||||||
|
|
@ -226,11 +226,7 @@ if config_env() == :prod do
|
||||||
# SMTP configuration from environment variables (overrides base adapter in prod).
|
# SMTP configuration from environment variables (overrides base adapter in prod).
|
||||||
# When SMTP_HOST is set, configure Swoosh to use the SMTP adapter at boot time.
|
# When SMTP_HOST is set, configure Swoosh to use the SMTP adapter at boot time.
|
||||||
# If SMTP is configured only via Settings (Admin UI), the mailer builds the config
|
# If SMTP is configured only via Settings (Admin UI), the mailer builds the config
|
||||||
# per-send at runtime using Mv.Config.smtp_*() helpers.
|
# per-send at runtime using Mv.Mailer.smtp_config/0 (which uses the same Mv.Smtp.ConfigBuilder).
|
||||||
#
|
|
||||||
# TLS/SSL options (tls_options, sockopts) are duplicated here and in Mv.Mailer.smtp_config/0
|
|
||||||
# because boot config must be set in this file; the Mailer uses the same logic for
|
|
||||||
# Settings-only config. Keep verify behaviour in sync (see SMTP_VERIFY_PEER below).
|
|
||||||
smtp_host_env = System.get_env("SMTP_HOST")
|
smtp_host_env = System.get_env("SMTP_HOST")
|
||||||
|
|
||||||
if smtp_host_env && String.trim(smtp_host_env) != "" do
|
if smtp_host_env && String.trim(smtp_host_env) != "" do
|
||||||
|
|
@ -264,20 +260,14 @@ if config_env() == :prod do
|
||||||
verify_mode = if smtp_verify_peer, do: :verify_peer, else: :verify_none
|
verify_mode = if smtp_verify_peer, do: :verify_peer, else: :verify_none
|
||||||
|
|
||||||
smtp_opts =
|
smtp_opts =
|
||||||
[
|
Mv.Smtp.ConfigBuilder.build_opts(
|
||||||
adapter: Swoosh.Adapters.SMTP,
|
host: String.trim(smtp_host_env),
|
||||||
relay: String.trim(smtp_host_env),
|
|
||||||
port: smtp_port_env,
|
port: smtp_port_env,
|
||||||
username: System.get_env("SMTP_USERNAME"),
|
username: System.get_env("SMTP_USERNAME"),
|
||||||
password: smtp_password_env,
|
password: smtp_password_env,
|
||||||
ssl: smtp_ssl_mode == "ssl",
|
ssl_mode: smtp_ssl_mode,
|
||||||
tls: if(smtp_ssl_mode == "tls", do: :always, else: :never),
|
verify_mode: verify_mode
|
||||||
auth: :always,
|
)
|
||||||
# tls_options: STARTTLS (587); sockopts: direct SSL (465).
|
|
||||||
tls_options: [verify: verify_mode],
|
|
||||||
sockopts: [verify: verify_mode]
|
|
||||||
]
|
|
||||||
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|
|
||||||
|
|
||||||
config :mv, Mv.Mailer, smtp_opts
|
config :mv, Mv.Mailer, smtp_opts
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,7 @@ By default, TLS certificate verification is relaxed (`verify_none`) so self-sign
|
||||||
- **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.
|
- **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.
|
- **Default:** `false` (verify_none) for backward compatibility and internal/self-signed certs.
|
||||||
|
|
||||||
Both `tls_options` (STARTTLS, port 587) and `sockopts` (direct SSL, port 465) use the same verify mode. The logic is duplicated in `config/runtime.exs` (boot) and `Mv.Mailer.smtp_config/0` (Settings-only); keep in sync.
|
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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -117,7 +117,7 @@ Both `tls_options` (STARTTLS, port 587) and `sockopts` (direct SSL, port 465) us
|
||||||
- [x] Password from file: `SMTP_PASSWORD_FILE` supported in `runtime.exs`.
|
- [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] 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] Per-request SMTP config via `Mv.Mailer.smtp_config/0` for Settings-only scenarios.
|
||||||
- [x] TLS certificate validation relaxed for OTP 27 (tls_options + sockopts).
|
- [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] Prod warning: clear message in Settings when SMTP is not configured.
|
||||||
- [x] Test email: form with recipient field, translatable content, classified success/error messages.
|
- [x] Test email: form with recipient field, translatable content, classified success/error messages.
|
||||||
- [x] Join confirmation email: uses `Mailer.smtp_config/0` (same as test mail); on failure returns `{:error, :email_delivery_failed}`, error shown in JoinLive, logged for admin.
|
- [x] 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.
|
||||||
|
|
|
||||||
|
|
@ -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)"
|
||||||
|
|
@ -409,6 +415,10 @@ defmodule Mv.Accounts.User do
|
||||||
validate {Mv.Accounts.User.Validations.RegistrationEnabled, []},
|
validate {Mv.Accounts.User.Validations.RegistrationEnabled, []},
|
||||||
where: [action_is(:register_with_password)]
|
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
|
||||||
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
|
||||||
|
|
@ -31,6 +31,7 @@ defmodule Mv.Mailer do
|
||||||
import Swoosh.Email
|
import Swoosh.Email
|
||||||
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
|
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
|
||||||
|
|
||||||
|
alias Mv.Smtp.ConfigBuilder
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
# Simple format check for test-email recipient only (e.g. allows a@b.c). Not for strict RFC validation.
|
# Simple format check for test-email recipient only (e.g. allows a@b.c). Not for strict RFC validation.
|
||||||
|
|
@ -100,31 +101,19 @@ defmodule Mv.Mailer do
|
||||||
@spec smtp_config() :: keyword()
|
@spec smtp_config() :: keyword()
|
||||||
def smtp_config do
|
def smtp_config do
|
||||||
if Mv.Config.smtp_configured?() and not boot_smtp_configured?() do
|
if Mv.Config.smtp_configured?() and not boot_smtp_configured?() do
|
||||||
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 =
|
||||||
if Application.get_env(:mv, :smtp_verify_peer, false),
|
if Application.get_env(:mv, :smtp_verify_peer, false),
|
||||||
do: :verify_peer,
|
do: :verify_peer,
|
||||||
else: :verify_none
|
else: :verify_none
|
||||||
|
|
||||||
[
|
ConfigBuilder.build_opts(
|
||||||
adapter: Swoosh.Adapters.SMTP,
|
host: Mv.Config.smtp_host(),
|
||||||
relay: host,
|
port: Mv.Config.smtp_port() || 587,
|
||||||
port: port,
|
username: Mv.Config.smtp_username(),
|
||||||
ssl: ssl_mode == "ssl",
|
password: Mv.Config.smtp_password(),
|
||||||
tls: if(ssl_mode == "tls", do: :always, else: :never),
|
ssl_mode: Mv.Config.smtp_ssl() || "tls",
|
||||||
auth: :always,
|
verify_mode: verify_mode
|
||||||
username: username,
|
)
|
||||||
password: password,
|
|
||||||
# tls_options: STARTTLS (587); sockopts: direct SSL (465). Verify from :smtp_verify_peer (ENV SMTP_VERIFY_PEER).
|
|
||||||
tls_options: [verify: verify_mode],
|
|
||||||
sockopts: [verify: verify_mode]
|
|
||||||
]
|
|
||||||
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|
|
||||||
else
|
else
|
||||||
[]
|
[]
|
||||||
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,23 +67,27 @@ 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
|
||||||
|
|
||||||
priv = :code.priv_dir(@app)
|
if bootstrap_seeds_applied?() and System.get_env("FORCE_SEEDS") != "true" do
|
||||||
bootstrap_path = Path.join(priv, "repo/seeds_bootstrap.exs")
|
IO.puts("Seeds already applied. Skipping. (Set FORCE_SEEDS=true to override)")
|
||||||
dev_path = Path.join(priv, "repo/seeds_dev.exs")
|
else
|
||||||
|
priv = :code.priv_dir(@app)
|
||||||
|
bootstrap_path = Path.join(priv, "repo/seeds_bootstrap.exs")
|
||||||
|
dev_path = Path.join(priv, "repo/seeds_dev.exs")
|
||||||
|
|
||||||
prev = Code.compiler_options()
|
prev = Code.compiler_options()
|
||||||
Code.compiler_options(ignore_module_conflict: true)
|
Code.compiler_options(ignore_module_conflict: true)
|
||||||
|
|
||||||
try do
|
try do
|
||||||
Code.eval_file(bootstrap_path)
|
Code.eval_file(bootstrap_path)
|
||||||
IO.puts("✅ Bootstrap seeds completed.")
|
IO.puts("✅ Bootstrap seeds completed.")
|
||||||
|
|
||||||
if System.get_env("RUN_DEV_SEEDS") == "true" do
|
if System.get_env("RUN_DEV_SEEDS") == "true" do
|
||||||
Code.eval_file(dev_path)
|
Code.eval_file(dev_path)
|
||||||
IO.puts("✅ Dev seeds completed.")
|
IO.puts("✅ Dev seeds completed.")
|
||||||
|
end
|
||||||
|
after
|
||||||
|
Code.compiler_options(prev)
|
||||||
end
|
end
|
||||||
after
|
|
||||||
Code.compiler_options(prev)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
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
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -265,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} />
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
## Events
|
## Events
|
||||||
- `validate` / `save` - Club settings form
|
- `validate` / `save` - Club settings form
|
||||||
- `toggle_registration_enabled` - Enable/disable direct registration (/register)
|
- `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
|
||||||
|
|
@ -80,6 +81,7 @@ 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, Mv.Config.oidc_client_secret_set?())
|
|> assign(:oidc_client_secret_set, Mv.Config.oidc_client_secret_set?())
|
||||||
|> assign(:registration_enabled, settings.registration_enabled != false)
|
|> assign(:registration_enabled, settings.registration_enabled != false)
|
||||||
|
|
@ -625,11 +627,30 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
class="checkbox checkbox-sm"
|
class="checkbox checkbox-sm"
|
||||||
checked={@registration_enabled}
|
checked={@registration_enabled}
|
||||||
phx-click="toggle_registration_enabled"
|
phx-click="toggle_registration_enabled"
|
||||||
|
disabled={@oidc_only}
|
||||||
aria-label={gettext("Allow direct registration (/register)")}
|
aria-label={gettext("Allow direct registration (/register)")}
|
||||||
/>
|
/>
|
||||||
<label for="registration-enabled-checkbox" class="cursor-pointer font-medium">
|
<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)")}
|
{gettext("Allow direct registration (/register)")}
|
||||||
</label>
|
</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>
|
</div>
|
||||||
|
|
||||||
<h3 class="font-medium mb-3">{gettext("OIDC (Single Sign-On)")}</h3>
|
<h3 class="font-medium mb-3">{gettext("OIDC (Single Sign-On)")}</h3>
|
||||||
|
|
@ -638,6 +659,38 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
{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
|
||||||
|
|
@ -744,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={
|
||||||
|
|
@ -868,18 +900,19 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
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(:registration_enabled, fresh_settings.registration_enabled != false)
|
|> assign(:registration_enabled, updated_settings.registration_enabled != false)
|
||||||
|> assign(:vereinfacht_api_key_set, present?(fresh_settings.vereinfacht_api_key))
|
|> assign(:vereinfacht_api_key_set, present?(updated_settings.vereinfacht_api_key))
|
||||||
|> assign(:oidc_client_secret_set, Mv.Config.oidc_client_secret_set?())
|
|> assign(:oidc_client_secret_set, Mv.Config.oidc_client_secret_set?())
|
||||||
|
|> assign(:oidc_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_configured, Mv.Config.smtp_configured?())
|
||||||
|> assign(:smtp_password_set, present?(Mv.Config.smtp_password()))
|
|> assign(:smtp_password_set, present?(Mv.Config.smtp_password()))
|
||||||
|
|
@ -916,19 +949,53 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("toggle_registration_enabled", _params, socket) do
|
def handle_event("toggle_registration_enabled", _params, socket) do
|
||||||
settings = socket.assigns.settings
|
if Mv.Config.oidc_only?() do
|
||||||
new_value = not socket.assigns.registration_enabled
|
{:noreply, socket}
|
||||||
|
else
|
||||||
|
settings = socket.assigns.settings
|
||||||
|
new_value = not socket.assigns.registration_enabled
|
||||||
|
|
||||||
case Membership.update_settings(settings, %{registration_enabled: new_value}) do
|
case Membership.update_settings(settings, %{registration_enabled: new_value}) do
|
||||||
{:ok, updated_settings} ->
|
{:ok, updated_settings} ->
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> assign(:settings, updated_settings)
|
|> assign(:settings, updated_settings)
|
||||||
|> assign(:registration_enabled, updated_settings.registration_enabled != false)
|
|> assign(:registration_enabled, updated_settings.registration_enabled != false)
|
||||||
|> assign_form()}
|
|> assign_form()}
|
||||||
|
|
||||||
{:error, _} ->
|
{:error, _} ->
|
||||||
{:noreply, put_flash(socket, :error, gettext("Failed to update setting."))}
|
{: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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -74,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}
|
||||||
|
|
@ -82,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]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -18,6 +18,7 @@ defmodule MvWeb.Router do
|
||||||
plug MvWeb.Plugs.CheckPagePermission
|
plug MvWeb.Plugs.CheckPagePermission
|
||||||
plug MvWeb.Plugs.JoinFormEnabled
|
plug MvWeb.Plugs.JoinFormEnabled
|
||||||
plug MvWeb.Plugs.RegistrationEnabled
|
plug MvWeb.Plugs.RegistrationEnabled
|
||||||
|
plug MvWeb.Plugs.OidcOnlySignInRedirect
|
||||||
end
|
end
|
||||||
|
|
||||||
pipeline :api do
|
pipeline :api do
|
||||||
|
|
|
||||||
|
|
@ -3854,7 +3854,7 @@ msgstr "Wenn deaktiviert, können sich Nutzer*innen nicht über /register anmeld
|
||||||
#: lib/mv_web/controllers/page_controller.ex
|
#: lib/mv_web/controllers/page_controller.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Home"
|
msgid "Home"
|
||||||
msgstr ""
|
msgstr "Startseite"
|
||||||
|
|
||||||
#: lib/mv_web/controllers/join_confirm_controller.ex
|
#: lib/mv_web/controllers/join_confirm_controller.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
|
@ -3895,3 +3895,13 @@ msgstr "Rolle %{name}"
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "User %{email}"
|
msgid "User %{email}"
|
||||||
msgstr "Benutzer*in %{email}"
|
msgstr "Benutzer*in %{email}"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Only OIDC sign-in is active. This option is disabled."
|
||||||
|
msgstr "Nur OIDC-Anmeldung ist aktiv. Diese Option ist deaktiviert."
|
||||||
|
|
||||||
|
#: lib/mv_web/controllers/auth_controller.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Only sign-in via Single Sign-On (SSO) is allowed."
|
||||||
|
msgstr "Nur Anmeldung per Single Sign-On (SSO) ist erlaubt."
|
||||||
|
|
|
||||||
|
|
@ -3895,3 +3895,13 @@ msgstr ""
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "User %{email}"
|
msgid "User %{email}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Only OIDC sign-in is active. This option is disabled."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/controllers/auth_controller.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Only sign-in via Single Sign-On (SSO) is allowed."
|
||||||
|
msgstr ""
|
||||||
|
|
|
||||||
|
|
@ -3895,3 +3895,13 @@ msgstr "Role %{name}"
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "User %{email}"
|
msgid "User %{email}"
|
||||||
msgstr "User %{email}"
|
msgstr "User %{email}"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Only OIDC sign-in is active. This option is disabled."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/controllers/auth_controller.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Only sign-in via Single Sign-On (SSO) is allowed."
|
||||||
|
msgstr ""
|
||||||
|
|
|
||||||
|
|
@ -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,19 +14,25 @@
|
||||||
# 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
|
||||||
# Always run bootstrap (fee types, custom fields, roles, admin, system user, settings)
|
IO.puts("Seeds already applied. Skipping. (Set FORCE_SEEDS=true to override)")
|
||||||
Code.eval_file("priv/repo/seeds_bootstrap.exs")
|
else
|
||||||
|
prev = Code.compiler_options()
|
||||||
|
Code.compiler_options(ignore_module_conflict: true)
|
||||||
|
|
||||||
# In dev and test only: run dev seeds (20 members, groups, custom field values)
|
try do
|
||||||
if Mix.env() in [:dev, :test] do
|
# Always run bootstrap (fee types, custom fields, roles, admin, system user, settings)
|
||||||
Code.eval_file("priv/repo/seeds_dev.exs")
|
Code.eval_file("priv/repo/seeds_bootstrap.exs")
|
||||||
|
|
||||||
|
# In dev and test only: run dev seeds (20 members, groups, custom field values)
|
||||||
|
if Mix.env() in [:dev, :test] do
|
||||||
|
Code.eval_file("priv/repo/seeds_dev.exs")
|
||||||
|
end
|
||||||
|
|
||||||
|
IO.puts("✅ All seeds completed.")
|
||||||
|
after
|
||||||
|
Code.compiler_options(prev)
|
||||||
end
|
end
|
||||||
|
|
||||||
IO.puts("✅ All seeds completed.")
|
|
||||||
after
|
|
||||||
Code.compiler_options(prev)
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -288,4 +288,31 @@ defmodule Mv.Accounts.UserAuthenticationTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "register_with_password when OIDC-only is enabled" do
|
||||||
|
alias Mv.Membership
|
||||||
|
|
||||||
|
test "returns error when OIDC-only is enabled" do
|
||||||
|
{:ok, settings} = Membership.get_settings()
|
||||||
|
original_oidc_only = Map.get(settings, :oidc_only, false)
|
||||||
|
{:ok, _} = Membership.update_settings(settings, %{oidc_only: true})
|
||||||
|
|
||||||
|
try do
|
||||||
|
attrs = %{
|
||||||
|
email: "newuser#{System.unique_integer([:positive])}@example.com",
|
||||||
|
password: "SecurePassword123"
|
||||||
|
}
|
||||||
|
|
||||||
|
result =
|
||||||
|
Mv.Accounts.User
|
||||||
|
|> Ash.Changeset.for_create(:register_with_password, attrs)
|
||||||
|
|> Ash.create()
|
||||||
|
|
||||||
|
assert {:error, _} = result
|
||||||
|
after
|
||||||
|
{:ok, s} = Membership.get_settings()
|
||||||
|
Membership.update_settings(s, %{oidc_only: original_oidc_only})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
89
test/mv/mailer_smtp_config_test.exs
Normal file
89
test/mv/mailer_smtp_config_test.exs
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
defmodule Mv.MailerSmtpConfigTest do
|
||||||
|
@moduledoc """
|
||||||
|
Unit tests for Mv.Mailer.smtp_config/0.
|
||||||
|
|
||||||
|
Ensures both port 587 (STARTTLS) and 465 (implicit SSL) work:
|
||||||
|
- 587: sockopts must NOT contain :verify (gen_tcp:connect would raise ArgumentError).
|
||||||
|
- 465: sockopts MUST contain :verify so initial ssl:connect uses verify_none/verify_peer.
|
||||||
|
Uses ENV to drive config; async: false.
|
||||||
|
"""
|
||||||
|
use Mv.DataCase, async: false
|
||||||
|
|
||||||
|
alias Mv.Mailer
|
||||||
|
|
||||||
|
defp set_smtp_env(key, value), do: System.put_env(key, value)
|
||||||
|
|
||||||
|
defp clear_smtp_env do
|
||||||
|
System.delete_env("SMTP_HOST")
|
||||||
|
System.delete_env("SMTP_PORT")
|
||||||
|
System.delete_env("SMTP_USERNAME")
|
||||||
|
System.delete_env("SMTP_PASSWORD")
|
||||||
|
System.delete_env("SMTP_PASSWORD_FILE")
|
||||||
|
System.delete_env("SMTP_SSL")
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "smtp_config/0" do
|
||||||
|
test "port 587 (TLS): does not include :verify in sockopts so gen_tcp:connect does not crash" do
|
||||||
|
set_smtp_env("SMTP_HOST", "smtp.example.com")
|
||||||
|
set_smtp_env("SMTP_PORT", "587")
|
||||||
|
set_smtp_env("SMTP_SSL", "tls")
|
||||||
|
|
||||||
|
config = Mailer.smtp_config()
|
||||||
|
|
||||||
|
assert config != [], "expected non-empty config when SMTP_HOST is set"
|
||||||
|
|
||||||
|
sockopts = Keyword.get(config, :sockopts, [])
|
||||||
|
|
||||||
|
refute Keyword.has_key?(sockopts, :verify),
|
||||||
|
"for 587 gen_tcp is used first; sockopts must not contain :verify"
|
||||||
|
after
|
||||||
|
clear_smtp_env()
|
||||||
|
end
|
||||||
|
|
||||||
|
test "port 465 (SSL): includes :verify in sockopts so initial ssl:connect accepts verify mode" do
|
||||||
|
set_smtp_env("SMTP_HOST", "smtp.example.com")
|
||||||
|
set_smtp_env("SMTP_PORT", "465")
|
||||||
|
set_smtp_env("SMTP_SSL", "ssl")
|
||||||
|
|
||||||
|
config = Mailer.smtp_config()
|
||||||
|
|
||||||
|
assert config != []
|
||||||
|
sockopts = Keyword.get(config, :sockopts, [])
|
||||||
|
|
||||||
|
assert Keyword.has_key?(sockopts, :verify),
|
||||||
|
"for 465 initial connection is ssl:connect; sockopts must contain :verify"
|
||||||
|
|
||||||
|
assert Keyword.get(sockopts, :verify) in [:verify_none, :verify_peer]
|
||||||
|
after
|
||||||
|
clear_smtp_env()
|
||||||
|
end
|
||||||
|
|
||||||
|
test "builds TLS mode for port 587 (STARTTLS)" do
|
||||||
|
set_smtp_env("SMTP_HOST", "smtp.example.com")
|
||||||
|
set_smtp_env("SMTP_PORT", "587")
|
||||||
|
set_smtp_env("SMTP_SSL", "tls")
|
||||||
|
|
||||||
|
config = Mailer.smtp_config()
|
||||||
|
|
||||||
|
assert config != []
|
||||||
|
assert Keyword.get(config, :tls) == :always
|
||||||
|
assert Keyword.get(config, :ssl) == false
|
||||||
|
after
|
||||||
|
clear_smtp_env()
|
||||||
|
end
|
||||||
|
|
||||||
|
test "builds SSL mode for port 465 (implicit SSL)" do
|
||||||
|
set_smtp_env("SMTP_HOST", "smtp.example.com")
|
||||||
|
set_smtp_env("SMTP_PORT", "465")
|
||||||
|
set_smtp_env("SMTP_SSL", "ssl")
|
||||||
|
|
||||||
|
config = Mailer.smtp_config()
|
||||||
|
|
||||||
|
assert config != []
|
||||||
|
assert Keyword.get(config, :ssl) == true
|
||||||
|
assert Keyword.get(config, :tls) == :never
|
||||||
|
after
|
||||||
|
clear_smtp_env()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
83
test/mv/smtp/config_builder_test.exs
Normal file
83
test/mv/smtp/config_builder_test.exs
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
defmodule Mv.Smtp.ConfigBuilderTest do
|
||||||
|
@moduledoc """
|
||||||
|
Unit tests for Mv.Smtp.ConfigBuilder.build_opts/1.
|
||||||
|
|
||||||
|
Ensures the single source of truth for SMTP opts correctly handles:
|
||||||
|
- Port 587 (TLS): sockopts must NOT contain :verify (gen_tcp rejects it).
|
||||||
|
- Port 465 (SSL): sockopts MUST contain :verify for initial ssl:connect.
|
||||||
|
"""
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias Mv.Smtp.ConfigBuilder
|
||||||
|
|
||||||
|
describe "build_opts/1" do
|
||||||
|
test "port 587 (TLS): does not include :verify in sockopts" do
|
||||||
|
opts =
|
||||||
|
ConfigBuilder.build_opts(
|
||||||
|
host: "smtp.example.com",
|
||||||
|
port: 587,
|
||||||
|
username: "user",
|
||||||
|
password: "secret",
|
||||||
|
ssl_mode: "tls",
|
||||||
|
verify_mode: :verify_none
|
||||||
|
)
|
||||||
|
|
||||||
|
sockopts = Keyword.get(opts, :sockopts, [])
|
||||||
|
refute Keyword.has_key?(sockopts, :verify), "for 587 sockopts must not contain :verify"
|
||||||
|
assert Keyword.get(opts, :tls) == :always
|
||||||
|
assert Keyword.get(opts, :ssl) == false
|
||||||
|
end
|
||||||
|
|
||||||
|
test "port 465 (SSL): includes :verify in sockopts" do
|
||||||
|
opts =
|
||||||
|
ConfigBuilder.build_opts(
|
||||||
|
host: "smtp.example.com",
|
||||||
|
port: 465,
|
||||||
|
username: "user",
|
||||||
|
password: "secret",
|
||||||
|
ssl_mode: "ssl",
|
||||||
|
verify_mode: :verify_peer
|
||||||
|
)
|
||||||
|
|
||||||
|
sockopts = Keyword.get(opts, :sockopts, [])
|
||||||
|
assert Keyword.has_key?(sockopts, :verify), "for 465 sockopts must contain :verify"
|
||||||
|
assert Keyword.get(sockopts, :verify) == :verify_peer
|
||||||
|
assert Keyword.get(opts, :ssl) == true
|
||||||
|
assert Keyword.get(opts, :tls) == :never
|
||||||
|
end
|
||||||
|
|
||||||
|
test "strips nil values" do
|
||||||
|
opts =
|
||||||
|
ConfigBuilder.build_opts(
|
||||||
|
host: "smtp.example.com",
|
||||||
|
port: 587,
|
||||||
|
username: nil,
|
||||||
|
password: nil,
|
||||||
|
ssl_mode: "tls",
|
||||||
|
verify_mode: :verify_none
|
||||||
|
)
|
||||||
|
|
||||||
|
refute Keyword.has_key?(opts, :username)
|
||||||
|
refute Keyword.has_key?(opts, :password)
|
||||||
|
assert Keyword.get(opts, :relay) == "smtp.example.com"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "includes adapter and required keys" do
|
||||||
|
opts =
|
||||||
|
ConfigBuilder.build_opts(
|
||||||
|
host: "mail.example.com",
|
||||||
|
port: 587,
|
||||||
|
username: "u",
|
||||||
|
password: "p",
|
||||||
|
ssl_mode: "tls",
|
||||||
|
verify_mode: :verify_none
|
||||||
|
)
|
||||||
|
|
||||||
|
assert Keyword.get(opts, :adapter) == Swoosh.Adapters.SMTP
|
||||||
|
assert Keyword.get(opts, :relay) == "mail.example.com"
|
||||||
|
assert Keyword.get(opts, :port) == 587
|
||||||
|
assert Keyword.get(opts, :auth) == :always
|
||||||
|
assert Keyword.get(opts, :tls_options) == [verify: :verify_none]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -283,6 +283,141 @@ defmodule MvWeb.AuthControllerTest do
|
||||||
assert to =~ "/auth/user/password/sign_in_with_token"
|
assert to =~ "/auth/user/password/sign_in_with_token"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "when OIDC-only is enabled" do
|
||||||
|
setup %{conn: authenticated_conn} do
|
||||||
|
{:ok, settings} = Membership.get_settings()
|
||||||
|
original_oidc_only = Map.get(settings, :oidc_only, false)
|
||||||
|
{:ok, _} = Membership.update_settings(settings, %{oidc_only: true})
|
||||||
|
|
||||||
|
conn = build_unauthenticated_conn(authenticated_conn)
|
||||||
|
{:ok, conn: conn, original_oidc_only: original_oidc_only}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "password sign-in is rejected and redirects to sign-in with error", %{
|
||||||
|
conn: conn,
|
||||||
|
original_oidc_only: original
|
||||||
|
} do
|
||||||
|
try do
|
||||||
|
_user =
|
||||||
|
create_test_user(%{
|
||||||
|
email: "password@example.com",
|
||||||
|
password: "secret123",
|
||||||
|
oidc_id: nil
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, view, _html} = live(conn, "/sign-in")
|
||||||
|
|
||||||
|
result =
|
||||||
|
view
|
||||||
|
|> form("#user-password-sign-in-with-password",
|
||||||
|
user: %{email: "password@example.com", password: "secret123"}
|
||||||
|
)
|
||||||
|
|> render_submit()
|
||||||
|
|
||||||
|
# When OIDC-only is enabled, password sign-in must not succeed (no redirect to sign_in_with_token).
|
||||||
|
case result do
|
||||||
|
{:error, {:redirect, %{to: to}}} ->
|
||||||
|
refute to =~ "sign_in_with_token",
|
||||||
|
"Expected password sign-in to be rejected when OIDC-only, got redirect to: #{to}"
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
# LiveView re-rendered (e.g. with flash error) instead of redirecting to success
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
after
|
||||||
|
{:ok, s} = Membership.get_settings()
|
||||||
|
Membership.update_settings(s, %{oidc_only: original})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "GET /sign-in when OIDC-only" do
|
||||||
|
test "redirects to OIDC flow when OIDC-only and OIDC are configured", %{
|
||||||
|
conn: authenticated_conn
|
||||||
|
} do
|
||||||
|
{:ok, settings} = Membership.get_settings()
|
||||||
|
|
||||||
|
prev = %{
|
||||||
|
oidc_only: settings.oidc_only,
|
||||||
|
oidc_client_id: settings.oidc_client_id,
|
||||||
|
oidc_base_url: settings.oidc_base_url,
|
||||||
|
oidc_redirect_uri: settings.oidc_redirect_uri
|
||||||
|
}
|
||||||
|
|
||||||
|
{:ok, _} =
|
||||||
|
Membership.update_settings(settings, %{
|
||||||
|
oidc_only: true,
|
||||||
|
oidc_client_id: "test-client",
|
||||||
|
oidc_base_url: "https://idp.example.com",
|
||||||
|
oidc_redirect_uri: "http://localhost:4000/auth/user/oidc/callback",
|
||||||
|
oidc_client_secret: "test-secret"
|
||||||
|
})
|
||||||
|
|
||||||
|
try do
|
||||||
|
conn = build_unauthenticated_conn(authenticated_conn)
|
||||||
|
conn = get(conn, ~p"/sign-in")
|
||||||
|
assert redirected_to(conn) =~ "/auth/user/oidc"
|
||||||
|
after
|
||||||
|
{:ok, s} = Membership.get_settings()
|
||||||
|
Membership.update_settings(s, prev)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns 200 when OIDC-only but oidc_failed=1 (avoids redirect loop)", %{
|
||||||
|
conn: authenticated_conn
|
||||||
|
} do
|
||||||
|
{:ok, settings} = Membership.get_settings()
|
||||||
|
|
||||||
|
prev = %{
|
||||||
|
oidc_only: settings.oidc_only,
|
||||||
|
oidc_client_id: settings.oidc_client_id,
|
||||||
|
oidc_base_url: settings.oidc_base_url,
|
||||||
|
oidc_redirect_uri: settings.oidc_redirect_uri
|
||||||
|
}
|
||||||
|
|
||||||
|
{:ok, _} =
|
||||||
|
Membership.update_settings(settings, %{
|
||||||
|
oidc_only: true,
|
||||||
|
oidc_client_id: "test-client",
|
||||||
|
oidc_base_url: "https://idp.example.com",
|
||||||
|
oidc_redirect_uri: "http://localhost:4000/auth/user/oidc/callback",
|
||||||
|
oidc_client_secret: "test-secret"
|
||||||
|
})
|
||||||
|
|
||||||
|
try do
|
||||||
|
conn = build_unauthenticated_conn(authenticated_conn)
|
||||||
|
conn = get(conn, "/sign-in?oidc_failed=1")
|
||||||
|
assert conn.status == 200
|
||||||
|
# Sign-in page is shown, not redirect to OIDC
|
||||||
|
assert conn.resp_body =~ "Sign in" or conn.resp_body =~ "sign-in"
|
||||||
|
after
|
||||||
|
{:ok, s} = Membership.get_settings()
|
||||||
|
Membership.update_settings(s, prev)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns 200 when OIDC-only but OIDC not configured", %{conn: authenticated_conn} do
|
||||||
|
{:ok, settings} = Membership.get_settings()
|
||||||
|
original_oidc_only = Map.get(settings, :oidc_only, false)
|
||||||
|
{:ok, _} = Membership.update_settings(settings, %{oidc_only: true})
|
||||||
|
|
||||||
|
try do
|
||||||
|
conn = build_unauthenticated_conn(authenticated_conn)
|
||||||
|
conn = get(conn, ~p"/sign-in")
|
||||||
|
assert conn.status == 200
|
||||||
|
after
|
||||||
|
{:ok, s} = Membership.get_settings()
|
||||||
|
Membership.update_settings(s, %{oidc_only: original_oidc_only})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns 200 when OIDC-only is disabled", %{conn: authenticated_conn} do
|
||||||
|
conn = build_unauthenticated_conn(authenticated_conn)
|
||||||
|
conn = get(conn, ~p"/sign-in")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# OIDC/Rauthy error handling tests
|
# OIDC/Rauthy error handling tests
|
||||||
describe "handle_oidc_failure/2" do
|
describe "handle_oidc_failure/2" do
|
||||||
test "Assent.ServerUnreachableError redirects to sign-in with error flash", %{
|
test "Assent.ServerUnreachableError redirects to sign-in with error flash", %{
|
||||||
|
|
@ -298,7 +433,7 @@ defmodule MvWeb.AuthControllerTest do
|
||||||
|
|
||||||
conn = MvWeb.AuthController.failure(conn, {:oidc, :callback}, error)
|
conn = MvWeb.AuthController.failure(conn, {:oidc, :callback}, error)
|
||||||
|
|
||||||
assert redirected_to(conn) == ~p"/sign-in"
|
assert redirected_to(conn) == "/sign-in?oidc_failed=1"
|
||||||
|
|
||||||
assert Phoenix.Flash.get(conn.assigns.flash, :error) ==
|
assert Phoenix.Flash.get(conn.assigns.flash, :error) ==
|
||||||
"The authentication server is currently unavailable. Please try again later."
|
"The authentication server is currently unavailable. Please try again later."
|
||||||
|
|
@ -320,7 +455,7 @@ defmodule MvWeb.AuthControllerTest do
|
||||||
|
|
||||||
conn = MvWeb.AuthController.failure(conn, {:oidc, :callback}, error)
|
conn = MvWeb.AuthController.failure(conn, {:oidc, :callback}, error)
|
||||||
|
|
||||||
assert redirected_to(conn) == ~p"/sign-in"
|
assert redirected_to(conn) == "/sign-in?oidc_failed=1"
|
||||||
|
|
||||||
assert Phoenix.Flash.get(conn.assigns.flash, :error) ==
|
assert Phoenix.Flash.get(conn.assigns.flash, :error) ==
|
||||||
"Authentication configuration error. Please contact the administrator."
|
"Authentication configuration error. Please contact the administrator."
|
||||||
|
|
@ -334,7 +469,7 @@ defmodule MvWeb.AuthControllerTest do
|
||||||
|
|
||||||
conn = MvWeb.AuthController.failure(conn, {:oidc, :callback}, unknown_reason)
|
conn = MvWeb.AuthController.failure(conn, {:oidc, :callback}, unknown_reason)
|
||||||
|
|
||||||
assert redirected_to(conn) == ~p"/sign-in"
|
assert redirected_to(conn) == "/sign-in?oidc_failed=1"
|
||||||
|
|
||||||
assert Phoenix.Flash.get(conn.assigns.flash, :error) ==
|
assert Phoenix.Flash.get(conn.assigns.flash, :error) ==
|
||||||
"Unable to authenticate with OIDC. Please try again."
|
"Unable to authenticate with OIDC. Please try again."
|
||||||
|
|
|
||||||
|
|
@ -110,4 +110,69 @@ defmodule MvWeb.GlobalSettingsLiveTest do
|
||||||
assert html =~ "SMTP" or html =~ "E-Mail" or html =~ "Settings"
|
assert html =~ "SMTP" or html =~ "E-Mail" or html =~ "Settings"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "Authentication section when OIDC-only is enabled" do
|
||||||
|
setup %{conn: conn} do
|
||||||
|
user = create_test_user(%{email: "admin@example.com"})
|
||||||
|
conn = conn_with_oidc_user(conn, user)
|
||||||
|
{:ok, settings} = Membership.get_settings()
|
||||||
|
original_oidc_only = Map.get(settings, :oidc_only, false)
|
||||||
|
{:ok, _} = Membership.update_settings(settings, %{oidc_only: true})
|
||||||
|
{:ok, conn: conn, original_oidc_only: original_oidc_only}
|
||||||
|
end
|
||||||
|
|
||||||
|
@describetag :ui
|
||||||
|
test "registration checkbox is disabled when OIDC-only is enabled", %{
|
||||||
|
conn: conn,
|
||||||
|
original_oidc_only: original
|
||||||
|
} do
|
||||||
|
try do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||||
|
assert has_element?(view, "#registration-enabled-checkbox[disabled]")
|
||||||
|
after
|
||||||
|
{:ok, s} = Membership.get_settings()
|
||||||
|
Membership.update_settings(s, %{oidc_only: original})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@describetag :ui
|
||||||
|
test "OIDC-only hint is visible when OIDC-only is enabled", %{
|
||||||
|
conn: conn,
|
||||||
|
original_oidc_only: original
|
||||||
|
} do
|
||||||
|
try do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||||
|
assert has_element?(view, "[data-testid='oidc-only-registration-hint']")
|
||||||
|
after
|
||||||
|
{:ok, s} = Membership.get_settings()
|
||||||
|
Membership.update_settings(s, %{oidc_only: original})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "when OIDC-only is disabled, registration checkbox is enabled and can be toggled", %{
|
||||||
|
conn: conn,
|
||||||
|
original_oidc_only: original
|
||||||
|
} do
|
||||||
|
try do
|
||||||
|
{:ok, settings} = Membership.get_settings()
|
||||||
|
Membership.update_settings(settings, %{oidc_only: false})
|
||||||
|
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||||
|
refute has_element?(view, "#registration-enabled-checkbox[disabled]")
|
||||||
|
|
||||||
|
initial_checked =
|
||||||
|
view |> element("#registration-enabled-checkbox") |> render() =~ "checked"
|
||||||
|
|
||||||
|
view
|
||||||
|
|> element("#registration-enabled-checkbox")
|
||||||
|
|> render_click()
|
||||||
|
|
||||||
|
new_checked = view |> element("#registration-enabled-checkbox") |> render() =~ "checked"
|
||||||
|
assert new_checked != initial_checked
|
||||||
|
after
|
||||||
|
{:ok, s} = Membership.get_settings()
|
||||||
|
Membership.update_settings(s, %{oidc_only: original})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@ defmodule MvWeb.JoinLiveEmailFailureTest do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
When join confirmation email delivery fails, the user sees an error message
|
When join confirmation email delivery fails, the user sees an error message
|
||||||
and no success copy. Uses FailingMailAdapter; async: false to avoid config races.
|
and no success copy. Uses FailingMailAdapter; async: false to avoid config races.
|
||||||
|
|
||||||
|
Ensures SMTP is not configured (ENV cleared, Settings smtp_host nil) so that
|
||||||
|
Mailer.smtp_config/0 returns [] and the Application-configured FailingMailAdapter is used.
|
||||||
"""
|
"""
|
||||||
use MvWeb.ConnCase, async: false
|
use MvWeb.ConnCase, async: false
|
||||||
import Phoenix.LiveViewTest
|
import Phoenix.LiveViewTest
|
||||||
|
|
@ -12,18 +15,21 @@ defmodule MvWeb.JoinLiveEmailFailureTest do
|
||||||
test "when confirmation email fails, user sees error flash and no success message", %{
|
test "when confirmation email fails, user sees error flash and no success message", %{
|
||||||
conn: conn
|
conn: conn
|
||||||
} do
|
} do
|
||||||
|
# Clear SMTP config so Mailer.smtp_config() returns [] and FailingMailAdapter is used.
|
||||||
|
saved_env = clear_smtp_env_and_save()
|
||||||
enable_join_form_for_test()
|
enable_join_form_for_test()
|
||||||
|
|
||||||
saved = Application.get_env(:mv, Mv.Mailer)
|
saved_mailer = Application.get_env(:mv, Mv.Mailer)
|
||||||
|
|
||||||
Application.put_env(
|
Application.put_env(
|
||||||
:mv,
|
:mv,
|
||||||
Mv.Mailer,
|
Mv.Mailer,
|
||||||
Keyword.put(saved || [], :adapter, Mv.TestSupport.FailingMailAdapter)
|
Keyword.put(saved_mailer || [], :adapter, Mv.TestSupport.FailingMailAdapter)
|
||||||
)
|
)
|
||||||
|
|
||||||
on_exit(fn ->
|
on_exit(fn ->
|
||||||
Application.put_env(:mv, Mv.Mailer, saved)
|
Application.put_env(:mv, Mv.Mailer, saved_mailer)
|
||||||
|
restore_smtp_env(saved_env)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/join")
|
{:ok, view, _html} = live(conn, "/join")
|
||||||
|
|
@ -46,13 +52,36 @@ defmodule MvWeb.JoinLiveEmailFailureTest do
|
||||||
refute view |> element("[data-testid='join-success-message']") |> has_element?()
|
refute view |> element("[data-testid='join-success-message']") |> has_element?()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp clear_smtp_env_and_save do
|
||||||
|
keys = [
|
||||||
|
"SMTP_HOST",
|
||||||
|
"SMTP_PORT",
|
||||||
|
"SMTP_USERNAME",
|
||||||
|
"SMTP_PASSWORD",
|
||||||
|
"SMTP_PASSWORD_FILE",
|
||||||
|
"SMTP_SSL"
|
||||||
|
]
|
||||||
|
|
||||||
|
saved = for k <- keys, into: %{}, do: {k, System.get_env(k)}
|
||||||
|
for k <- keys, do: System.delete_env(k)
|
||||||
|
saved
|
||||||
|
end
|
||||||
|
|
||||||
|
defp restore_smtp_env(saved) do
|
||||||
|
for {k, v} <- saved do
|
||||||
|
if v != nil, do: System.put_env(k, v), else: System.delete_env(k)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp enable_join_form_for_test do
|
defp enable_join_form_for_test do
|
||||||
{:ok, settings} = Membership.get_settings()
|
{:ok, settings} = Membership.get_settings()
|
||||||
|
|
||||||
Membership.update_settings(settings, %{
|
Membership.update_settings(settings, %{
|
||||||
join_form_enabled: true,
|
join_form_enabled: true,
|
||||||
join_form_field_ids: ["email", "first_name", "last_name"],
|
join_form_field_ids: ["email", "first_name", "last_name"],
|
||||||
join_form_field_required: %{"email" => true, "first_name" => false, "last_name" => false}
|
join_form_field_required: %{"email" => true, "first_name" => false, "last_name" => false},
|
||||||
|
# Clear SMTP host so Mv.Config.smtp_configured?() is false and Mailer.smtp_config() returns [].
|
||||||
|
smtp_host: nil
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -181,13 +181,14 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "unauthenticated user" do
|
describe "unauthenticated user" do
|
||||||
test "nil current_user is denied and redirected to \"/sign-in\"" do
|
test "nil current_user is denied and redirected to \"/sign-in\" without access-denied flash" do
|
||||||
conn = conn_without_user("/members") |> CheckPagePermission.call([])
|
conn = conn_without_user("/members") |> CheckPagePermission.call([])
|
||||||
|
|
||||||
assert conn.halted
|
assert conn.halted
|
||||||
assert redirected_to(conn) == "/sign-in"
|
assert redirected_to(conn) == "/sign-in"
|
||||||
|
|
||||||
assert Phoenix.Flash.get(conn.assigns[:flash] || %{}, :error) ==
|
# Unauthenticated users are redirected to sign-in only; no "no permission" message.
|
||||||
|
refute Phoenix.Flash.get(conn.assigns[:flash] || %{}, :error) ==
|
||||||
"You don't have permission to access this page."
|
"You don't have permission to access this page."
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue