diff --git a/.actrc b/.actrc new file mode 100644 index 00000000..eaeb72d6 --- /dev/null +++ b/.actrc @@ -0,0 +1,15 @@ +# Use native arm64 containers on M-series Macs. setup-beam auto-picks +# the right Erlang/OTP prebuilt for the container arch; forcing amd64 +# emulation (act's default on arm64 hosts) can leave you with binaries +# built for a different arch than the one actually running. +# +# Pin Ubuntu 20.04: setup-beam's arm64 Erlang/OTP prebuilds on +# builds.hex.pm are ONLY built against Ubuntu 20.04 (libssl1.1) — +# there is no arm64/ubuntu-22.04 variant at the time of writing. +# Using a newer Ubuntu image (22.04/24.04) breaks OTP's :crypto NIF +# with `libcrypto.so.1.1: cannot open shared object file`, which in +# turn breaks `mix local.rebar` (can't make HTTPS requests). +-P ubuntu-20.04=catthehacker/ubuntu:act-20.04 +-P ubuntu-22.04=catthehacker/ubuntu:act-20.04 +-P ubuntu-24.04=catthehacker/ubuntu:act-20.04 +-P ubuntu-latest=catthehacker/ubuntu:act-20.04 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c70127b..caed6ef3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,8 +23,8 @@ jobs: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - uses: erlef/setup-beam@fc68ffb90438ef2936bbb3251622353b3dcb2f93 # v1.24.0 with: - otp-version: '27.3' - elixir-version: '1.18.4' + version-file: .tool-versions + version-type: strict - name: Cache library deps uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: @@ -60,15 +60,15 @@ jobs: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - uses: erlef/setup-beam@fc68ffb90438ef2936bbb3251622353b3dcb2f93 # v1.24.0 with: - otp-version: '27.3' - elixir-version: '1.18.4' + version-file: .tool-versions + version-type: strict - name: Cache example deps uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: | test/example/deps test/example/_build - key: ${{ runner.os }}-example-${{ hashFiles('test/example/mix.lock') }} + key: ${{ runner.os }}-example-${{ hashFiles('test/example/mix.lock', 'test/example/config/**', 'test/example/lib/**/*.ex', 'lib/**/*.ex', 'mix.exs') }} - name: Fetch example deps working-directory: test/example run: mix deps.get @@ -108,8 +108,8 @@ jobs: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - uses: erlef/setup-beam@fc68ffb90438ef2936bbb3251622353b3dcb2f93 # v1.24.0 with: - otp-version: '27.3' - elixir-version: '1.18.4' + version-file: .tool-versions + version-type: strict - name: Cache Sigra library deps uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: @@ -155,15 +155,15 @@ jobs: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - uses: erlef/setup-beam@fc68ffb90438ef2936bbb3251622353b3dcb2f93 # v1.24.0 with: - otp-version: '27.3' - elixir-version: '1.18.4' + version-file: .tool-versions + version-type: strict - name: Cache example deps uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: | test/example/deps test/example/_build - key: ${{ runner.os }}-example-dev-${{ hashFiles('test/example/mix.lock') }} + key: ${{ runner.os }}-example-dev-${{ hashFiles('test/example/mix.lock', 'test/example/config/**', 'test/example/lib/**/*.ex', 'lib/**/*.ex', 'mix.exs') }} - name: Fetch example deps working-directory: test/example env: @@ -210,8 +210,8 @@ jobs: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - uses: erlef/setup-beam@fc68ffb90438ef2936bbb3251622353b3dcb2f93 # v1.24.0 with: - otp-version: '27.3' - elixir-version: '1.18.4' + version-file: .tool-versions + version-type: strict - uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4.0.4 with: node-version: '20' @@ -223,7 +223,7 @@ jobs: path: | test/example/deps test/example/_build - key: ${{ runner.os }}-example-dev-${{ hashFiles('test/example/mix.lock') }} + key: ${{ runner.os }}-example-dev-${{ hashFiles('test/example/mix.lock', 'test/example/config/**', 'test/example/lib/**/*.ex', 'lib/**/*.ex', 'mix.exs') }} - name: Fetch example deps working-directory: test/example env: @@ -257,15 +257,25 @@ jobs: PGHOST: localhost PHX_SERVER: "true" run: mix phx.server & - - name: Wait for app to respond + - name: Wait for app and warm up LiveView routes run: | + # Wait for root. for i in $(seq 1 30); do if curl -sf http://localhost:4000/ > /dev/null; then - echo "App responding after ${i}s"; exit 0 + echo "App responding after ${i}s" + break fi sleep 1 done - echo "FAIL: app did not respond within 30s"; exit 1 + # Warm up the routes the test will hit. With `plug_init_mode: + # :runtime` in dev, first-touch of each route pays compile-on- + # demand latency — without this, Playwright's first toHaveURL + # check on /users/register races the first LiveView compile. + for path in /users/register /users/log_in /users/confirm /dev/mailbox /users/sessions /users/sudo /users/settings/mfa; do + curl -sf -o /dev/null "http://localhost:4000${path}" \ + && echo "warmed ${path}" \ + || echo "warmup miss ${path} (non-fatal)" + done - name: Run Playwright golden-path spec working-directory: test/example/priv/playwright env: diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 00000000..73a996a7 --- /dev/null +++ b/.tool-versions @@ -0,0 +1,2 @@ +erlang 28.0 +elixir 1.19.5-otp-28 diff --git a/lib/mix/tasks/sigra.install.ex b/lib/mix/tasks/sigra.install.ex index 1599ce64..e56ca0c2 100644 --- a/lib/mix/tasks/sigra.install.ex +++ b/lib/mix/tasks/sigra.install.ex @@ -539,8 +539,12 @@ defmodule Mix.Tasks.Sigra.Install do # Oban queue injection (per D-22) inject_oban_queue(otp_app) - # Swoosh config detection (per D-19/D-55) - inject_swoosh_config(otp_app, context_module) + # Swoosh config detection (per D-19/D-55). Adapter must be configured on + # the raw Swoosh.Mailer (`.Mailer`), NOT on the Sigra behaviour + # wrapper (`.Mailer`), since the wrapper delegates to the + # raw mailer and only the raw mailer calls `Swoosh.Mailer.deliver/2`. + app_module = binding[:app_module] + inject_swoosh_config(otp_app, app_module) end defp inject_api_files(binding) do @@ -681,7 +685,7 @@ defmodule Mix.Tasks.Sigra.Install do end end - defp inject_swoosh_config(otp_app, context_module) do + defp inject_swoosh_config(otp_app, app_module) do dev_config = Path.join(["config", "dev.exs"]) if File.exists?(dev_config) do @@ -692,8 +696,9 @@ defmodule Mix.Tasks.Sigra.Install do else swoosh_block = """ - # Sigra email delivery (dev) - config :#{otp_app}, #{context_module}.Mailer, + # Sigra email delivery (dev) — adapter is set on the raw Swoosh.Mailer + # module, not the Sigra.Mailer behaviour wrapper. + config :#{otp_app}, #{app_module}.Mailer, adapter: Swoosh.Adapters.Local config :swoosh, :api_client, false diff --git a/lib/sigra/delivery.ex b/lib/sigra/delivery.ex index da8b91b4..0e2f0413 100644 --- a/lib/sigra/delivery.ex +++ b/lib/sigra/delivery.ex @@ -102,8 +102,15 @@ defmodule Sigra.Delivery do defp delivery_mode(opts) do case Keyword.get(opts, :delivery_mode, :auto) do - :auto -> if Code.ensure_loaded?(Oban), do: :async, else: :sync + :auto -> if oban_running?(), do: :async, else: :sync mode -> mode end end + + # :auto must only route to :async when Oban is actually supervised in the + # host app — not merely compiled/loadable. Apps that add `{:oban, ...}` to + # mix.exs without wiring the supervisor would otherwise crash on insert. + defp oban_running? do + Code.ensure_loaded?(Oban) and Process.whereis(Oban) != nil + end end diff --git a/lib/sigra/mfa.ex b/lib/sigra/mfa.ex index 5f848dcc..13fed017 100644 --- a/lib/sigra/mfa.ex +++ b/lib/sigra/mfa.ex @@ -152,14 +152,18 @@ defmodule Sigra.MFA do codes = BackupCodes.generate(backup_count) + # Backup codes are effectively write-once — only `used_at` ever + # changes when a code is consumed. The shipped schemas use + # `timestamps(updated_at: false)`, so we only populate inserted_at. + # Any consumer schema that DOES have updated_at will get it + # auto-populated via its own changeset path, not this bulk insert. entries = Enum.map(codes, fn {_formatted, hashed} -> %{ user_id: user.id, hashed_code: hashed, used_at: nil, - inserted_at: now, - updated_at: now + inserted_at: now } end) @@ -549,10 +553,20 @@ defmodule Sigra.MFA do """ @doc since: "0.6.0" - @spec status(Sigra.Config.t(), struct()) :: map() - def status(%Sigra.Config{} = config, user) do - mfa_credential_schema = Keyword.get(config.mfa, :mfa_credential_schema) - backup_code_schema = Keyword.get(config.mfa, :backup_code_schema) + @spec status(Sigra.Config.t(), struct(), keyword()) :: map() + def status(%Sigra.Config{} = config, user, opts \\ []) do + # The `config.mfa` keyword list is validated by NimbleOptions and does + # NOT accept `:mfa_credential_schema` or `:backup_code_schema` — those + # are per-call opts, the same pattern used by confirm_enrollment/3, + # verify/4, and disable/4. Fall back to config.mfa so callers that + # previously used an un-validated config still work. + mfa_credential_schema = + Keyword.get(opts, :mfa_credential_schema) || + Keyword.get(config.mfa || [], :mfa_credential_schema) + + backup_code_schema = + Keyword.get(opts, :backup_code_schema) || + Keyword.get(config.mfa || [], :backup_code_schema) if mfa_credential_schema do case config.repo.get_by(mfa_credential_schema, user_id: user.id) do diff --git a/lib/sigra/workers/account_deletion.ex b/lib/sigra/workers/account_deletion.ex index c3b0757c..082222b3 100644 --- a/lib/sigra/workers/account_deletion.ex +++ b/lib/sigra/workers/account_deletion.ex @@ -1,3 +1,4 @@ +if Code.ensure_loaded?(Oban.Worker) do defmodule Sigra.Workers.AccountDeletion do @moduledoc """ Oban worker for executing scheduled account deletions. @@ -80,3 +81,4 @@ defmodule Sigra.Workers.AccountDeletion do from(t in "user_tokens", where: t.user_id == ^user.id) end end +end diff --git a/lib/sigra/workers/audit_cleanup.ex b/lib/sigra/workers/audit_cleanup.ex index 99ccf940..e14613bf 100644 --- a/lib/sigra/workers/audit_cleanup.ex +++ b/lib/sigra/workers/audit_cleanup.ex @@ -1,3 +1,4 @@ +if Code.ensure_loaded?(Oban.Worker) do defmodule Sigra.Workers.AuditCleanup do @moduledoc """ Optional Oban worker that deletes audit rows older than the configured @@ -53,3 +54,4 @@ defmodule Sigra.Workers.AuditCleanup do Sigra.Audit.do_cleanup(repo, audit_schema, retention_days) end end +end diff --git a/lib/sigra/workers/email_delivery.ex b/lib/sigra/workers/email_delivery.ex index 14c24aa2..900bfff3 100644 --- a/lib/sigra/workers/email_delivery.ex +++ b/lib/sigra/workers/email_delivery.ex @@ -1,3 +1,4 @@ +if Code.ensure_loaded?(Oban.Worker) do defmodule Sigra.Workers.EmailDelivery do @moduledoc """ Oban worker for asynchronous email delivery. @@ -99,3 +100,4 @@ defmodule Sigra.Workers.EmailDelivery do end end end +end diff --git a/lib/sigra/workers/token_cleanup.ex b/lib/sigra/workers/token_cleanup.ex index 901ca6aa..c81e5ecd 100644 --- a/lib/sigra/workers/token_cleanup.ex +++ b/lib/sigra/workers/token_cleanup.ex @@ -1,3 +1,4 @@ +if Code.ensure_loaded?(Oban.Worker) do defmodule Sigra.Workers.TokenCleanup do @moduledoc """ Oban cron worker for cleaning up expired tokens. @@ -219,3 +220,4 @@ defmodule Sigra.Workers.TokenCleanup do defp get_repo(%{"repo" => repo_string}), do: String.to_existing_atom(repo_string) defp get_token_schema(%{"token_schema" => schema_string}), do: String.to_existing_atom(schema_string) end +end diff --git a/mix.exs b/mix.exs index fcdd7d17..d67e3c43 100644 --- a/mix.exs +++ b/mix.exs @@ -10,6 +10,7 @@ defmodule Sigra.MixProject do version: @version, elixir: "~> 1.18", elixirc_paths: elixirc_paths(Mix.env()), + elixirc_options: elixirc_options(), start_permanent: Mix.env() == :prod, deps: deps(), # test_load_filters: tells `mix test` which files to load via require. @@ -40,6 +41,38 @@ defmodule Sigra.MixProject do defp elixirc_paths(:test), do: ["lib", "test/support"] defp elixirc_paths(_), do: ["lib"] + # Silence undefined-function warnings for optional deps and for internal + # modules that are conditionally compiled behind optional-dep guards. When + # Sigra is pulled in by a consumer that doesn't add these deps to its own + # mix.exs, the compiler would otherwise warn on every reference and break + # `mix compile --warnings-as-errors` downstream. + defp elixirc_options do + [ + no_warn_undefined: [ + # Optional deps (mix.exs: optional: true) + Bcrypt, + Hammer, + Swoosh.Email, + Oban, + Oban.Worker, + Oban.Job, + Assent.Strategy.Apple, + Assent.Strategy.Facebook, + Assent.Strategy.Github, + Assent.Strategy.Google, + Joken, + Joken.Signer, + Joken.Config, + EQRCode, + # Internal modules defined only when an optional dep is loaded + Sigra.Workers.AccountDeletion, + Sigra.Workers.AuditCleanup, + Sigra.Workers.EmailDelivery, + Sigra.Workers.TokenCleanup + ] + ] + end + defp deps do [ {:phoenix, "~> 1.8"}, diff --git a/priv/templates/sigra.install/auth.ex b/priv/templates/sigra.install/auth.ex index 8d950542..ca8ac025 100644 --- a/priv/templates/sigra.install/auth.ex +++ b/priv/templates/sigra.install/auth.ex @@ -261,6 +261,21 @@ defmodule <%= context_module %> do hashed token in Sigra's canonical `user_sessions` store. """ def get_user_by_session_token(raw_token) when is_binary(raw_token) do + case get_user_and_session_by_token(raw_token) do + {user, _session} -> user + nil -> nil + end + end + + def get_user_by_session_token(_), do: nil + + @doc """ + Looks up both the user and the session record by raw session cookie + token. Returns `{user, session}` on success or `nil` on failure. Used + by code paths that need the session record itself — e.g. the sudo + controller needs `session.hashed_token` to mark sudo confirmation. + """ + def get_user_and_session_by_token(raw_token) when is_binary(raw_token) do with {:ok, raw_bytes} <- Base.url_decode64(raw_token, padding: false) do hashed = Sigra.Token.hash_token(raw_bytes) config = sigra_config() @@ -273,15 +288,21 @@ defmodule <%= context_module %> do ] case store.fetch(hashed, store_opts) do - {:ok, session} -> Repo.get(<%= schema_alias %>, session.user_id) - {:error, :not_found} -> nil + {:ok, session} -> + case Repo.get(<%= schema_alias %>, session.user_id) do + nil -> nil + user -> {user, session} + end + + {:error, :not_found} -> + nil end else _ -> nil end end - def get_user_by_session_token(_), do: nil + def get_user_and_session_by_token(_), do: nil @doc """ Deletes the session identified by the given raw token from @@ -611,7 +632,10 @@ defmodule <%= context_module %> do @doc "Get MFA status for a user (enrollment state, backup code count, etc.)." def mfa_status(user) do - Sigra.MFA.status(sigra_config(), user) + Sigra.MFA.status(sigra_config(), user, + mfa_credential_schema: <%= context_module %>.UserMFACredential, + backup_code_schema: <%= context_module %>.UserBackupCode + ) end ## Account Lifecycle diff --git a/priv/templates/sigra.install/reactivation_live.ex b/priv/templates/sigra.install/reactivation_live.ex index 9aa56c1f..74682a6b 100644 --- a/priv/templates/sigra.install/reactivation_live.ex +++ b/priv/templates/sigra.install/reactivation_live.ex @@ -1,4 +1,4 @@ -defmodule <%= web_module %>.Auth.ReactivationLive do +defmodule <%= web_module %>.ReactivationLive do @moduledoc """ LiveView for account reactivation during the deletion grace period. diff --git a/priv/templates/sigra.install/settings_live.ex b/priv/templates/sigra.install/settings_live.ex index a06a4403..45976294 100644 --- a/priv/templates/sigra.install/settings_live.ex +++ b/priv/templates/sigra.install/settings_live.ex @@ -1,4 +1,4 @@ -defmodule <%= web_module %>.Auth.SettingsLive do +defmodule <%= web_module %>.SettingsLive do @moduledoc """ LiveView for account settings management. diff --git a/priv/templates/sigra.install/user_auth.ex b/priv/templates/sigra.install/user_auth.ex index 2552ecd1..bac892d4 100644 --- a/priv/templates/sigra.install/user_auth.ex +++ b/priv/templates/sigra.install/user_auth.ex @@ -127,9 +127,18 @@ defmodule <%= web_module %>.UserAuth do """ def fetch_current_scope(conn, _opts) do {user_token, conn} = ensure_user_token(conn) - user = user_token && <%= context_module %>.get_user_by_session_token(user_token) + + {user, session} = + case user_token && <%= context_module %>.get_user_and_session_by_token(user_token) do + {u, s} -> {u, s} + _ -> {nil, nil} + end + scope = user && Scope.for_user(user) - assign(conn, :current_scope, scope) + + conn + |> put_private(:sigra_session, session) + |> assign(:current_scope, scope) end defp ensure_user_token(conn) do diff --git a/scripts/ci/install-smoke.sh b/scripts/ci/install-smoke.sh index db7fc6a2..f10f892e 100755 --- a/scripts/ci/install-smoke.sh +++ b/scripts/ci/install-smoke.sh @@ -29,10 +29,9 @@ cd "$(dirname "${TMP_APP_DIR}")" # mix archive.install phx_new MUST already be in place (installed by the CI # step before this script runs). -yes Y | mix phx.new "$(basename "${TMP_APP_DIR}")" \ +mix phx.new "$(basename "${TMP_APP_DIR}")" \ --no-install \ --no-dashboard \ - --no-gettext \ --database postgres cd "${TMP_APP_DIR}" diff --git a/scripts/uat/RUNBOOK.md b/scripts/uat/RUNBOOK.md index ca8846d0..db7fb911 100644 --- a/scripts/uat/RUNBOOK.md +++ b/scripts/uat/RUNBOOK.md @@ -538,6 +538,67 @@ open doc/index.html # macOS; xdg-open on Linux --- +## Running CI locally with `act` (Phase 10.1.1) + +`act` runs the `.github/workflows/ci.yml` workflow inside Docker containers +that closely mirror the real GitHub Actions runner. It's the fastest way to +iterate on CI changes without the push → wait → red-build loop. + +### One-time setup + +```bash +brew install act # requires Docker Desktop running +docker pull --platform linux/arm64 catthehacker/ubuntu:act-20.04 # M-series macs +``` + +An `.actrc` at the repo root pins `ubuntu-latest` (and `-20.04`, `-22.04`, +`-24.04`) to `catthehacker/ubuntu:act-20.04`. **Do not change this without +understanding the reason:** `erlef/setup-beam` only publishes arm64 Erlang/OTP +prebuilts for Ubuntu 20.04 (libssl1.1). Newer Ubuntu images (22/24) break the +`:crypto` NIF with `libcrypto.so.1.1: cannot open shared object file`, which +in turn breaks `mix local.rebar` (can't make HTTPS requests). + +### Port 5432 collision + +Act's postgres service binds `0.0.0.0:5432`, so anything already listening +there (Homebrew Postgres, UAT compose stack, stale Docker containers) will +block the job setup: + +```bash +lsof -i :5432 # find who owns it +docker stop sigra-uat-postgres 2>/dev/null # if that's the culprit +brew services stop postgresql@14 # if Homebrew Postgres +``` + +Restart whatever you stopped when the act run finishes. + +### Common commands + +```bash +act -l # list jobs +act -j library_tests # run one job +act -j example_playwright_smoke --reuse # --reuse keeps container warm +act --graph # draw the workflow graph +act -j --verbose # full stdout, not just grouped output +``` + +`--reuse` is the big performance win — without it, act rebuilds the container +and re-fetches deps on every run. With it, the second run skips `mix deps.get`, +`npm ci`, and the chromium download entirely, dropping a full Playwright run +from ~12 minutes to ~90 seconds. + +### Troubleshooting + +- **`EACCES: permission denied, rmdir '/opt/hostedtoolcache/...'`** — The + image starts as a non-root user. Fix with `--container-options --user=0:0` + in `.actrc` (already set). +- **`Bind for 0.0.0.0:5432 failed: port is already allocated`** — See the + Port 5432 collision section above. +- **`Unable to load crypto library ... libcrypto.so.1.1`** — You're running + against an Ubuntu 22/24 image. Switch back to `act-20.04`. + +--- + ## Branch protection — required status checks (Phase 10.1.1) After merging phase 10.1.1 (example-app repair + CI smoke harness), update diff --git a/test/example/config/dev.exs b/test/example/config/dev.exs index 88dc0ae2..5239bd4e 100644 --- a/test/example/config/dev.exs +++ b/test/example/config/dev.exs @@ -85,8 +85,10 @@ config :phoenix_live_view, # Enable helpful, but potentially expensive runtime checks enable_expensive_runtime_checks: true -# Sigra email delivery (dev) -config :example, Example.Accounts.Mailer, +# Sigra email delivery (dev) — adapter is set on the raw Swoosh.Mailer module +# (`Example.Mailer`), not the Sigra.Mailer behaviour wrapper +# (`Example.Accounts.Mailer`), which delegates to `Example.Mailer.deliver/1`. +config :example, Example.Mailer, adapter: Swoosh.Adapters.Local config :swoosh, :api_client, false diff --git a/test/example/lib/example/accounts.ex b/test/example/lib/example/accounts.ex index 0a002b05..facf5420 100644 --- a/test/example/lib/example/accounts.ex +++ b/test/example/lib/example/accounts.ex @@ -261,6 +261,22 @@ defmodule Example.Accounts do hashed token in Sigra's canonical `user_sessions` store. """ def get_user_by_session_token(raw_token) when is_binary(raw_token) do + case get_user_and_session_by_token(raw_token) do + {user, _session} -> user + nil -> nil + end + end + + def get_user_by_session_token(_), do: nil + + @doc """ + Looks up both the user and the session record by raw session cookie + token. Returns `{user, session}` on success or `nil` on failure. This + is the companion to `get_user_by_session_token/1` used by code paths + (like `SudoController.create/2`) that need the session's hashed_token + to mark sudo confirmation. + """ + def get_user_and_session_by_token(raw_token) when is_binary(raw_token) do with {:ok, raw_bytes} <- Base.url_decode64(raw_token, padding: false) do hashed = Sigra.Token.hash_token(raw_bytes) config = sigra_config() @@ -273,15 +289,21 @@ defmodule Example.Accounts do ] case store.fetch(hashed, store_opts) do - {:ok, session} -> Repo.get(User, session.user_id) - {:error, :not_found} -> nil + {:ok, session} -> + case Repo.get(User, session.user_id) do + nil -> nil + user -> {user, session} + end + + {:error, :not_found} -> + nil end else _ -> nil end end - def get_user_by_session_token(_), do: nil + def get_user_and_session_by_token(_), do: nil @doc """ Deletes the session identified by the given raw token from @@ -613,7 +635,10 @@ defmodule Example.Accounts do @doc "Get MFA status for a user (enrollment state, backup code count, etc.)." def mfa_status(user) do - Sigra.MFA.status(sigra_config(), user) + Sigra.MFA.status(sigra_config(), user, + mfa_credential_schema: Example.Accounts.UserMFACredential, + backup_code_schema: Example.Accounts.UserBackupCode + ) end ## Account Lifecycle diff --git a/test/example/lib/example_web/user_auth.ex b/test/example/lib/example_web/user_auth.ex index 7ff79bce..c488934e 100644 --- a/test/example/lib/example_web/user_auth.ex +++ b/test/example/lib/example_web/user_auth.ex @@ -127,9 +127,18 @@ defmodule ExampleWeb.UserAuth do """ def fetch_current_scope(conn, _opts) do {user_token, conn} = ensure_user_token(conn) - user = user_token && Example.Accounts.get_user_by_session_token(user_token) + + {user, session} = + case user_token && Example.Accounts.get_user_and_session_by_token(user_token) do + {u, s} -> {u, s} + _ -> {nil, nil} + end + scope = user && Scope.for_user(user) - assign(conn, :current_scope, scope) + + conn + |> put_private(:sigra_session, session) + |> assign(:current_scope, scope) end defp ensure_user_token(conn) do diff --git a/test/example/priv/playwright/fixtures/mailbox.ts b/test/example/priv/playwright/fixtures/mailbox.ts index 1d6e33b9..328f28e7 100644 --- a/test/example/priv/playwright/fixtures/mailbox.ts +++ b/test/example/priv/playwright/fixtures/mailbox.ts @@ -2,12 +2,17 @@ // most-recent confirmation link. // // Swoosh's MailboxPreview renders a two-pane UI: a list of emails on the left, -// and the selected email body inside an