diff --git a/docs/plans/2026-02-01-sudo-mode-mfa-only.md b/docs/plans/2026-02-01-sudo-mode-mfa-only.md
new file mode 100644
index 00000000..c0b34cf7
--- /dev/null
+++ b/docs/plans/2026-02-01-sudo-mode-mfa-only.md
@@ -0,0 +1,898 @@
+# Sudo Mode MFA-Only Verification Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Replace full login requirement for sudo mode with TOTP-only verification at dedicated `/users/sudo-verify` endpoint.
+
+**Architecture:** Add new controller for sudo verification, modify UserAuth plugs to redirect to TOTP-only page instead of full login, add Accounts functions for granting sudo mode and verifying TOTP without recovery codes.
+
+**Tech Stack:** Phoenix 1.8, Ecto, Phoenix.Controller, Plug, ExUnit
+
+---
+
+## Task 1: Add Accounts.grant_sudo_mode/1 function
+
+**Files:**
+- Modify: `lib/towerops/accounts/user.ex`
+- Modify: `lib/towerops/accounts.ex`
+- Create: `test/towerops/accounts/grant_sudo_mode_test.exs`
+
+**Step 1: Write the failing test**
+
+Create `test/towerops/accounts/grant_sudo_mode_test.exs`:
+
+```elixir
+defmodule Towerops.Accounts.GrantSudoModeTest do
+ use Towerops.DataCase, async: true
+
+ import Towerops.AccountsFixtures
+
+ alias Towerops.Accounts
+
+ describe "grant_sudo_mode/1" do
+ test "updates user's last_sudo_at timestamp" do
+ user = user_fixture()
+ assert is_nil(user.last_sudo_at)
+
+ {:ok, updated_user} = Accounts.grant_sudo_mode(user)
+
+ assert %DateTime{} = updated_user.last_sudo_at
+ assert DateTime.diff(DateTime.utc_now(), updated_user.last_sudo_at, :second) < 2
+ end
+
+ test "returns error if user is invalid" do
+ user = user_fixture()
+ # Pass invalid user struct
+ assert {:error, _changeset} = Accounts.grant_sudo_mode(%{user | id: nil})
+ end
+ end
+end
+```
+
+**Step 2: Run test to verify it fails**
+
+Run: `mix test test/towerops/accounts/grant_sudo_mode_test.exs`
+
+Expected: FAIL with "function Accounts.grant_sudo_mode/1 is undefined"
+
+**Step 3: Add sudo_changeset to User schema**
+
+In `lib/towerops/accounts/user.ex`, add after the existing changesets:
+
+```elixir
+@doc """
+Changeset for updating sudo mode timestamp.
+"""
+def sudo_changeset(user, attrs) do
+ user
+ |> cast(attrs, [:last_sudo_at])
+ |> validate_required([:last_sudo_at])
+end
+```
+
+**Step 4: Add grant_sudo_mode/1 to Accounts context**
+
+In `lib/towerops/accounts.ex`, add after the `sudo_mode?/2` function:
+
+```elixir
+@doc """
+Grants sudo mode to a user by updating their last_sudo_at timestamp.
+
+Returns `{:ok, user}` on success or `{:error, changeset}` on failure.
+
+## Examples
+
+ iex> grant_sudo_mode(user)
+ {:ok, %User{last_sudo_at: ~U[2026-02-01 12:00:00Z]}}
+"""
+def grant_sudo_mode(%User{} = user) do
+ now = DateTime.utc_now()
+
+ user
+ |> User.sudo_changeset(%{last_sudo_at: now})
+ |> Repo.update()
+end
+```
+
+**Step 5: Run test to verify it passes**
+
+Run: `mix test test/towerops/accounts/grant_sudo_mode_test.exs`
+
+Expected: PASS (2 tests)
+
+**Step 6: Commit**
+
+```bash
+git add lib/towerops/accounts/user.ex lib/towerops/accounts.ex test/towerops/accounts/grant_sudo_mode_test.exs
+git commit -m "feat: add Accounts.grant_sudo_mode/1 function"
+```
+
+---
+
+## Task 2: Add Accounts.verify_totp_only/2 function
+
+**Files:**
+- Modify: `lib/towerops/accounts.ex`
+- Create: `test/towerops/accounts/verify_totp_only_test.exs`
+
+**Step 1: Write the failing test**
+
+Create `test/towerops/accounts/verify_totp_only_test.exs`:
+
+```elixir
+defmodule Towerops.Accounts.VerifyTotpOnlyTest do
+ use Towerops.DataCase, async: true
+
+ import Towerops.AccountsFixtures
+
+ alias Towerops.Accounts
+
+ describe "verify_totp_only/2" do
+ setup do
+ user = user_fixture()
+
+ # Create TOTP device
+ {:ok, device} =
+ Accounts.create_user_totp_device(user, %{
+ name: "Test Device",
+ secret: NimbleTOTP.secret()
+ })
+
+ %{user: user, device: device}
+ end
+
+ test "accepts valid TOTP codes", %{user: user, device: device} do
+ code = NimbleTOTP.verification_code(device.secret)
+
+ assert {:ok, returned_user} = Accounts.verify_totp_only(user, code)
+ assert returned_user.id == user.id
+ end
+
+ test "rejects invalid TOTP codes", %{user: user} do
+ assert {:error, :invalid_code} = Accounts.verify_totp_only(user, "000000")
+ end
+
+ test "rejects recovery codes", %{user: user} do
+ # Recovery codes are longer than 6 digits
+ recovery_code = "ABCD-EFGH-IJKL"
+
+ assert {:error, :recovery_code_not_allowed} =
+ Accounts.verify_totp_only(user, recovery_code)
+ end
+
+ test "rejects codes longer than 6 digits", %{user: user} do
+ assert {:error, :recovery_code_not_allowed} =
+ Accounts.verify_totp_only(user, "1234567890")
+ end
+ end
+end
+```
+
+**Step 2: Run test to verify it fails**
+
+Run: `mix test test/towerops/accounts/verify_totp_only_test.exs`
+
+Expected: FAIL with "function Accounts.verify_totp_only/2 is undefined"
+
+**Step 3: Add verify_totp_only/2 to Accounts context**
+
+In `lib/towerops/accounts.ex`, add after the `verify_user_mfa/2` function:
+
+```elixir
+@doc """
+Verifies a TOTP code for a user, rejecting recovery codes.
+
+This is used for sudo mode verification where we want to ensure
+the user has access to their authenticator device, not just a recovery code.
+
+Returns `{:ok, user}` on success with valid TOTP code.
+Returns `{:error, :recovery_code_not_allowed}` if a recovery code is provided.
+Returns `{:error, reason}` for invalid TOTP codes.
+
+## Examples
+
+ iex> verify_totp_only(user, "123456")
+ {:ok, %User{}}
+
+ iex> verify_totp_only(user, "ABCD-EFGH-IJKL")
+ {:error, :recovery_code_not_allowed}
+
+ iex> verify_totp_only(user, "000000")
+ {:error, :invalid_code}
+"""
+def verify_totp_only(%User{} = user, code) when is_binary(code) do
+ # Reject codes longer than 6 digits (likely recovery codes)
+ if String.length(String.replace(code, ~r/[^0-9]/, "")) > 6 do
+ {:error, :recovery_code_not_allowed}
+ else
+ case verify_user_mfa(user, code) do
+ {:ok, user, :totp} -> {:ok, user}
+ {:ok, _user, :recovery_code} -> {:error, :recovery_code_not_allowed}
+ {:error, reason} -> {:error, reason}
+ end
+ end
+end
+```
+
+**Step 4: Run test to verify it passes**
+
+Run: `mix test test/towerops/accounts/verify_totp_only_test.exs`
+
+Expected: PASS (4 tests)
+
+**Step 5: Commit**
+
+```bash
+git add lib/towerops/accounts.ex test/towerops/accounts/verify_totp_only_test.exs
+git commit -m "feat: add Accounts.verify_totp_only/2 function"
+```
+
+---
+
+## Task 3: Create UserSudoController and HTML module
+
+**Files:**
+- Create: `lib/towerops_web/controllers/user_sudo_controller.ex`
+- Create: `lib/towerops_web/controllers/user_sudo_html.ex`
+- Create: `lib/towerops_web/controllers/user_sudo_html/verify.html.heex`
+- Create: `test/towerops_web/controllers/user_sudo_controller_test.exs`
+
+**Step 1: Write the failing test**
+
+Create `test/towerops_web/controllers/user_sudo_controller_test.exs`:
+
+```elixir
+defmodule ToweropsWeb.UserSudoControllerTest do
+ use ToweropsWeb.ConnCase, async: true
+
+ import Towerops.AccountsFixtures
+
+ alias Towerops.Accounts
+
+ setup do
+ user = user_fixture()
+
+ # Create TOTP device
+ {:ok, device} =
+ Accounts.create_user_totp_device(user, %{
+ name: "Test Device",
+ secret: NimbleTOTP.secret()
+ })
+
+ %{user: user, device: device}
+ end
+
+ describe "GET /users/sudo-verify" do
+ test "redirects to login when not authenticated", %{conn: conn} do
+ conn = get(conn, ~p"/users/sudo-verify")
+ assert redirected_to(conn) == ~p"/users/log-in"
+ end
+
+ test "shows verification form for authenticated users", %{conn: conn, user: user} do
+ conn = conn |> log_in_user(user) |> get(~p"/users/sudo-verify")
+
+ response = html_response(conn, 200)
+ assert response =~ "Verify Your Identity"
+ assert response =~ "Enter the code from your authenticator app"
+ end
+
+ test "redirects to settings if already in sudo mode", %{conn: conn, user: user} do
+ {:ok, user} = Accounts.grant_sudo_mode(user)
+
+ conn = conn |> log_in_user(user) |> get(~p"/users/sudo-verify")
+
+ assert redirected_to(conn) == ~p"/users/settings"
+ end
+
+ test "redirects to return path if already in sudo mode", %{conn: conn, user: user} do
+ {:ok, user} = Accounts.grant_sudo_mode(user)
+
+ conn =
+ conn
+ |> log_in_user(user)
+ |> put_session(:user_return_to, "/some/path")
+ |> get(~p"/users/sudo-verify")
+
+ assert redirected_to(conn) == "/some/path"
+ end
+
+ test "shows error if user has no TOTP devices", %{conn: conn} do
+ user_no_totp = user_fixture(%{email: "nototp@example.com"})
+
+ conn = conn |> log_in_user(user_no_totp) |> get(~p"/users/sudo-verify")
+
+ assert redirected_to(conn) == ~p"/account/totp-enrollment"
+ assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Two-factor authentication"
+ end
+ end
+
+ describe "POST /users/sudo-verify" do
+ test "redirects to login when not authenticated", %{conn: conn} do
+ conn = post(conn, ~p"/users/sudo-verify", %{"user" => %{"totp_code" => "123456"}})
+ assert redirected_to(conn) == ~p"/users/log-in"
+ end
+
+ test "grants sudo mode with valid TOTP code", %{conn: conn, user: user, device: device} do
+ code = NimbleTOTP.verification_code(device.secret)
+
+ conn =
+ conn
+ |> log_in_user(user)
+ |> post(~p"/users/sudo-verify", %{"user" => %{"totp_code" => code}})
+
+ assert redirected_to(conn) == ~p"/users/settings"
+
+ # Verify sudo mode was granted
+ updated_user = Accounts.get_user!(user.id)
+ assert Accounts.sudo_mode?(updated_user, -10)
+ end
+
+ test "redirects to return path after verification", %{conn: conn, user: user, device: device} do
+ code = NimbleTOTP.verification_code(device.secret)
+
+ conn =
+ conn
+ |> log_in_user(user)
+ |> put_session(:user_return_to, "/custom/path")
+ |> post(~p"/users/sudo-verify", %{"user" => %{"totp_code" => code}})
+
+ assert redirected_to(conn) == "/custom/path"
+ end
+
+ test "shows error with invalid TOTP code", %{conn: conn, user: user} do
+ conn =
+ conn
+ |> log_in_user(user)
+ |> post(~p"/users/sudo-verify", %{"user" => %{"totp_code" => "000000"}})
+
+ response = html_response(conn, 200)
+ assert response =~ "Invalid verification code"
+ end
+
+ test "rejects recovery codes with specific error", %{conn: conn, user: user} do
+ # Create a recovery code
+ {:ok, codes} = Accounts.generate_recovery_codes(user)
+ recovery_code = hd(codes)
+
+ conn =
+ conn
+ |> log_in_user(user)
+ |> post(~p"/users/sudo-verify", %{"user" => %{"totp_code" => recovery_code}})
+
+ response = html_response(conn, 200)
+ assert response =~ "Please use your authenticator app code"
+ assert response =~ "not a recovery code"
+ end
+ end
+end
+```
+
+**Step 2: Run test to verify it fails**
+
+Run: `mix test test/towerops_web/controllers/user_sudo_controller_test.exs`
+
+Expected: FAIL with "no route found" or "module not found"
+
+**Step 3: Create UserSudoController**
+
+Create `lib/towerops_web/controllers/user_sudo_controller.ex`:
+
+```elixir
+defmodule ToweropsWeb.UserSudoController do
+ @moduledoc """
+ Controller for sudo mode verification via TOTP.
+
+ Provides a lightweight re-authentication flow that only requires TOTP
+ instead of full email + password + TOTP login.
+ """
+ use ToweropsWeb, :controller
+
+ alias Towerops.Accounts
+
+ plug :require_authenticated_user
+
+ @doc """
+ Shows the TOTP verification form for sudo mode.
+
+ If user is already in sudo mode, redirects to return path or settings.
+ If user has no TOTP devices, redirects to enrollment.
+ """
+ def new(conn, _params) do
+ user = conn.assigns.current_scope.user
+
+ cond do
+ # Already in sudo mode, redirect to destination
+ Accounts.sudo_mode?(user, -10) ->
+ redirect_to_return_path(conn)
+
+ # No TOTP devices, must enroll first
+ !Accounts.totp_enabled?(user) ->
+ conn
+ |> put_flash(:error, "Two-factor authentication is required for this action.")
+ |> redirect(to: ~p"/account/totp-enrollment")
+
+ # Show verification form
+ true ->
+ form = Phoenix.Component.to_form(%{}, as: "user")
+ render(conn, :verify, form: form)
+ end
+ end
+
+ @doc """
+ Verifies the TOTP code and grants sudo mode.
+
+ On success, redirects to return path or settings.
+ On failure, re-renders form with error.
+ """
+ def verify(conn, %{"user" => %{"totp_code" => code}}) do
+ user = conn.assigns.current_scope.user
+
+ case Accounts.verify_totp_only(user, code) do
+ {:ok, _user} ->
+ # Grant sudo mode
+ {:ok, _updated_user} = Accounts.grant_sudo_mode(user)
+
+ conn
+ |> put_flash(:info, "Identity verified successfully.")
+ |> redirect_to_return_path()
+
+ {:error, :recovery_code_not_allowed} ->
+ form = Phoenix.Component.to_form(%{"totp_code" => code}, as: "user")
+
+ conn
+ |> put_flash(:error, "Please use your authenticator app code, not a recovery code.")
+ |> render(:verify, form: form)
+
+ {:error, _reason} ->
+ form = Phoenix.Component.to_form(%{"totp_code" => code}, as: "user")
+
+ conn
+ |> put_flash(:error, "Invalid verification code. Please try again.")
+ |> render(:verify, form: form)
+ end
+ end
+
+ defp redirect_to_return_path(conn) do
+ return_path = get_session(conn, :user_return_to) || ~p"/users/settings"
+ conn = delete_session(conn, :user_return_to)
+ redirect(conn, to: return_path)
+ end
+
+ defp require_authenticated_user(conn, _opts) do
+ if conn.assigns.current_scope && conn.assigns.current_scope.user do
+ conn
+ else
+ conn
+ |> put_flash(:error, "You must log in to access this page.")
+ |> redirect(to: ~p"/users/log-in")
+ |> halt()
+ end
+ end
+end
+```
+
+**Step 4: Create UserSudoHTML module**
+
+Create `lib/towerops_web/controllers/user_sudo_html.ex`:
+
+```elixir
+defmodule ToweropsWeb.UserSudoHTML do
+ @moduledoc """
+ HTML templates for sudo mode verification.
+ """
+ use ToweropsWeb, :html
+
+ embed_templates "user_sudo_html/*"
+end
+```
+
+**Step 5: Create verify.html.heex template**
+
+Create `lib/towerops_web/controllers/user_sudo_html/verify.html.heex`:
+
+```heex
+
+ <.header class="text-center">
+ Verify Your Identity
+ <:subtitle>Enter the code from your authenticator app to continue
+
+
+ <.simple_form :let={f} for={@form} action={~p"/users/sudo-verify"} phx-update="ignore">
+ <.error :if={@flash["error"]}>
+ <%= @flash["error"] %>
+
+
+ <.input
+ field={f[:totp_code]}
+ type="text"
+ label="Verification Code"
+ placeholder="000000"
+ required
+ autofocus
+ autocomplete="off"
+ inputmode="numeric"
+ pattern="[0-9]*"
+ maxlength="6"
+ />
+
+ <:actions>
+ <.button phx-disable-with="Verifying..." class="w-full">
+ Verify
+
+
+
+
+
+ Need help? <.link href={~p"/users/settings"} class="text-brand hover:underline">Go back
+
+
+```
+
+**Step 6: Add routes to router**
+
+In `lib/towerops_web/router.ex`, find the scope with `pipe_through [:browser, :require_authenticated_user]` and add:
+
+```elixir
+get "/users/sudo-verify", UserSudoController, :new
+post "/users/sudo-verify", UserSudoController, :verify
+```
+
+**Step 7: Run test to verify it passes**
+
+Run: `mix test test/towerops_web/controllers/user_sudo_controller_test.exs`
+
+Expected: PASS (10 tests)
+
+**Step 8: Commit**
+
+```bash
+git add lib/towerops_web/controllers/user_sudo_controller.ex lib/towerops_web/controllers/user_sudo_html.ex lib/towerops_web/controllers/user_sudo_html/verify.html.heex lib/towerops_web/router.ex test/towerops_web/controllers/user_sudo_controller_test.exs
+git commit -m "feat: add UserSudoController for TOTP-only verification"
+```
+
+---
+
+## Task 4: Update UserAuth plugs to redirect to sudo verify
+
+**Files:**
+- Modify: `lib/towerops_web/user_auth.ex`
+- Modify: `test/towerops_web/user_auth_test.exs`
+
+**Step 1: Write failing tests for updated behavior**
+
+In `test/towerops_web/user_auth_test.exs`, find the `describe "require_sudo_mode/2"` block and update it:
+
+```elixir
+describe "require_sudo_mode/2" do
+ test "redirects to sudo verify when sudo mode expired and TOTP enabled", %{conn: conn} do
+ user = user_fixture()
+
+ # Create TOTP device
+ {:ok, _device} =
+ Accounts.create_user_totp_device(user, %{
+ name: "Test Device",
+ secret: NimbleTOTP.secret()
+ })
+
+ conn =
+ conn
+ |> log_in_user(user)
+ |> UserAuth.require_sudo_mode([])
+
+ assert redirected_to(conn) == ~p"/users/sudo-verify"
+ assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "verify your identity"
+ end
+
+ test "redirects to TOTP enrollment when sudo mode expired and no TOTP", %{conn: conn} do
+ user = user_fixture()
+
+ conn =
+ conn
+ |> log_in_user(user)
+ |> UserAuth.require_sudo_mode([])
+
+ assert redirected_to(conn) == ~p"/account/totp-enrollment"
+ assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Two-factor authentication"
+ end
+
+ test "allows access when sudo mode is active", %{conn: conn} do
+ user = user_fixture()
+ {:ok, user} = Accounts.grant_sudo_mode(user)
+
+ conn =
+ conn
+ |> log_in_user(user)
+ |> UserAuth.require_sudo_mode([])
+
+ refute conn.halted
+ end
+end
+```
+
+**Step 2: Run test to verify it fails**
+
+Run: `mix test test/towerops_web/user_auth_test.exs::`
+
+(Replace `` with the line number of the describe block)
+
+Expected: FAIL - tests expect old redirect behavior
+
+**Step 3: Update require_sudo_mode/2 plug**
+
+In `lib/towerops_web/user_auth.ex`, replace the `require_sudo_mode/2` function:
+
+```elixir
+@doc """
+Plug for routes that require sudo mode.
+
+Checks if the user's sudo mode is active (within 10 minutes).
+If TOTP is enabled, redirects to TOTP-only verification page.
+If TOTP is not enabled, redirects to enrollment.
+"""
+def require_sudo_mode(conn, _opts) do
+ user = conn.assigns.current_scope.user
+
+ case {Accounts.sudo_mode?(user, -10), Accounts.totp_enabled?(user)} do
+ {true, _} ->
+ conn
+
+ {false, true} ->
+ conn
+ |> put_flash(:error, "Please verify your identity to continue.")
+ |> maybe_store_return_to()
+ |> redirect(to: ~p"/users/sudo-verify")
+ |> halt()
+
+ {false, false} ->
+ conn
+ |> put_flash(:error, "Two-factor authentication is required for this action.")
+ |> redirect(to: ~p"/account/totp-enrollment")
+ |> halt()
+ end
+end
+```
+
+**Step 4: Run test to verify it passes**
+
+Run: `mix test test/towerops_web/user_auth_test.exs`
+
+Expected: PASS for require_sudo_mode tests
+
+**Step 5: Write failing tests for on_mount hook**
+
+In `test/towerops_web/user_auth_test.exs`, find or create the `describe "on_mount: :require_sudo_mode"` block:
+
+```elixir
+describe "on_mount: :require_sudo_mode" do
+ test "redirects to sudo verify when sudo mode expired and TOTP enabled" do
+ user = user_fixture()
+
+ # Create TOTP device
+ {:ok, _device} =
+ Accounts.create_user_totp_device(user, %{
+ name: "Test Device",
+ secret: NimbleTOTP.secret()
+ })
+
+ socket = %Phoenix.LiveView.Socket{}
+ socket = Phoenix.Component.assign(socket, :current_scope, Scope.for_user(user))
+
+ assert {:halt, redirected_socket} =
+ UserAuth.on_mount(:require_sudo_mode, %{}, %{}, socket)
+
+ assert Phoenix.Flash.get(redirected_socket.assigns.flash, :error) =~ "verify your identity"
+ end
+
+ test "redirects to TOTP enrollment when sudo mode expired and no TOTP" do
+ user = user_fixture()
+
+ socket = %Phoenix.LiveView.Socket{}
+ socket = Phoenix.Component.assign(socket, :current_scope, Scope.for_user(user))
+
+ assert {:halt, redirected_socket} =
+ UserAuth.on_mount(:require_sudo_mode, %{}, %{}, socket)
+
+ assert Phoenix.Flash.get(redirected_socket.assigns.flash, :error) =~ "Two-factor authentication"
+ end
+
+ test "allows access when sudo mode is active" do
+ user = user_fixture()
+ {:ok, user} = Accounts.grant_sudo_mode(user)
+
+ socket = %Phoenix.LiveView.Socket{}
+ socket = Phoenix.Component.assign(socket, :current_scope, Scope.for_user(user))
+
+ assert {:cont, _socket} = UserAuth.on_mount(:require_sudo_mode, %{}, %{}, socket)
+ end
+end
+```
+
+**Step 6: Run test to verify it fails**
+
+Run: `mix test test/towerops_web/user_auth_test.exs`
+
+Expected: FAIL for on_mount tests
+
+**Step 7: Update on_mount(:require_sudo_mode) hook**
+
+In `lib/towerops_web/user_auth.ex`, replace the `on_mount(:require_sudo_mode)` clause:
+
+```elixir
+def on_mount(:require_sudo_mode, _params, _session, socket) do
+ user = socket.assigns.current_scope && socket.assigns.current_scope.user
+
+ case {user && Accounts.sudo_mode?(user, -10), user && Accounts.totp_enabled?(user)} do
+ {true, _} ->
+ {:cont, socket}
+
+ {false, true} ->
+ socket =
+ socket
+ |> LiveView.put_flash(:error, "Please verify your identity to continue.")
+ |> LiveView.redirect(to: ~p"/users/sudo-verify")
+
+ {:halt, socket}
+
+ {false, false} ->
+ socket =
+ socket
+ |> LiveView.put_flash(:error, "Two-factor authentication is required for this action.")
+ |> LiveView.redirect(to: ~p"/account/totp-enrollment")
+
+ {:halt, socket}
+
+ _ ->
+ {:halt, LiveView.redirect(socket, to: ~p"/users/log-in")}
+ end
+end
+```
+
+**Step 8: Run test to verify all tests pass**
+
+Run: `mix test test/towerops_web/user_auth_test.exs`
+
+Expected: PASS for all UserAuth tests
+
+**Step 9: Commit**
+
+```bash
+git add lib/towerops_web/user_auth.ex test/towerops_web/user_auth_test.exs
+git commit -m "feat: update UserAuth to redirect to sudo verify page"
+```
+
+---
+
+## Task 5: Update UserSettingsLive tests
+
+**Files:**
+- Modify: `test/towerops_web/live/user_settings_live_test.exs`
+
+**Step 1: Add helper to grant sudo mode in setup**
+
+In `test/towerops_web/live/user_settings_live_test.exs`, update the setup block:
+
+```elixir
+setup %{conn: conn} do
+ user = user_fixture()
+
+ # Grant sudo mode so tests can access settings page
+ {:ok, user} = Accounts.grant_sudo_mode(user)
+
+ %{conn: log_in_user(conn, user), user: user}
+end
+```
+
+**Step 2: Add test for sudo mode redirect**
+
+Add a new describe block in the same file:
+
+```elixir
+describe "sudo mode requirement" do
+ test "redirects to sudo verify when sudo mode expired", %{conn: conn} do
+ user = user_fixture()
+ # Don't grant sudo mode
+
+ {:error, {:redirect, %{to: path}}} =
+ live(log_in_user(conn, user), ~p"/users/settings")
+
+ assert path == "/users/sudo-verify"
+ end
+
+ test "allows access when sudo mode is active", %{conn: conn, user: user} do
+ # Sudo mode already granted in setup
+ {:ok, _view, html} = live(conn, ~p"/users/settings")
+
+ assert html =~ "Account Settings"
+ end
+end
+```
+
+**Step 3: Run tests to verify they pass**
+
+Run: `mix test test/towerops_web/live/user_settings_live_test.exs`
+
+Expected: PASS for all UserSettingsLive tests
+
+**Step 4: Commit**
+
+```bash
+git add test/towerops_web/live/user_settings_live_test.exs
+git commit -m "test: update UserSettingsLive tests for sudo mode"
+```
+
+---
+
+## Task 6: Run full test suite and format
+
+**Step 1: Run all tests**
+
+Run: `mix test`
+
+Expected: All tests pass
+
+**Step 2: Format code**
+
+Run: `mix format`
+
+Expected: All files formatted
+
+**Step 3: Run precommit checks**
+
+Run: `mix precommit`
+
+Expected: All checks pass (compile, format, test)
+
+**Step 4: Commit if any formatting changes**
+
+```bash
+git add -u
+git commit -m "chore: format code"
+```
+
+---
+
+## Task 7: Manual testing
+
+**Step 1: Start the server**
+
+Run: `mix phx.server`
+
+**Step 2: Test the flow**
+
+1. Log in as a user with TOTP enabled
+2. Navigate to `/users/settings`
+3. Should redirect to `/users/sudo-verify` (if sudo mode expired)
+4. Enter valid TOTP code
+5. Should redirect back to `/users/settings`
+6. Verify you can access settings without re-verifying for 10 minutes
+
+**Step 3: Test error cases**
+
+1. Try entering invalid TOTP code - should show error
+2. Try entering recovery code - should show specific error message
+3. Navigate to `/users/sudo-verify` while already in sudo mode - should redirect to settings
+
+**Step 4: Document any issues found**
+
+If any issues are found, create new tasks to fix them.
+
+---
+
+## Completion Checklist
+
+- [ ] Task 1: Add Accounts.grant_sudo_mode/1 function
+- [ ] Task 2: Add Accounts.verify_totp_only/2 function
+- [ ] Task 3: Create UserSudoController and HTML module
+- [ ] Task 4: Update UserAuth plugs to redirect to sudo verify
+- [ ] Task 5: Update UserSettingsLive tests
+- [ ] Task 6: Run full test suite and format
+- [ ] Task 7: Manual testing
+
+## Success Criteria
+
+✅ All tests pass
+✅ Code formatted and precommit checks pass
+✅ User can verify sudo mode with TOTP only (no password)
+✅ Recovery codes are rejected with clear error message
+✅ Return path redirection works correctly
+✅ Edge cases handled gracefully
diff --git a/lib/towerops/accounts.ex b/lib/towerops/accounts.ex
index 7263375e..c349091e 100644
--- a/lib/towerops/accounts.ex
+++ b/lib/towerops/accounts.ex
@@ -677,7 +677,7 @@ defmodule Towerops.Accounts do
def grant_sudo_mode(%User{} = user) do
now = DateTime.utc_now()
- with {:ok, _updated_user} = result <-
+ with {:ok, updated_user} <-
user
|> User.sudo_changeset(%{last_sudo_at: now})
|> Repo.update() do
@@ -688,7 +688,8 @@ defmodule Towerops.Accounts do
metadata: %{granted_at: now}
)
- result
+ # Set authenticated_at virtual field for sudo_mode? check
+ {:ok, %{updated_user | authenticated_at: now}}
end
end
diff --git a/lib/towerops_web/controllers/user_sudo_controller.ex b/lib/towerops_web/controllers/user_sudo_controller.ex
new file mode 100644
index 00000000..cf1a64a4
--- /dev/null
+++ b/lib/towerops_web/controllers/user_sudo_controller.ex
@@ -0,0 +1,71 @@
+defmodule ToweropsWeb.UserSudoController do
+ @moduledoc """
+ Controller for sudo mode verification.
+
+ Handles re-authentication for sensitive operations by requiring TOTP verification
+ (no recovery codes allowed).
+ """
+
+ use ToweropsWeb, :controller
+
+ import ToweropsWeb.UserAuth, only: [require_authenticated_user: 2]
+
+ alias Towerops.Accounts
+
+ plug :require_authenticated_user
+
+ def verify(conn, _params) do
+ # Render the sudo verification form
+ form = Phoenix.Component.to_form(%{}, as: "user")
+ render(conn, :verify, form: form)
+ end
+
+ def create(conn, %{"user" => %{"totp_code" => totp_code}}) do
+ user = conn.assigns.current_scope.user
+
+ case Accounts.verify_totp_only(user, totp_code) do
+ {:ok, _verified_user} ->
+ # Grant sudo mode
+ case Accounts.grant_sudo_mode(user) do
+ {:ok, _updated_user} ->
+ # Redirect to return_to path or default
+ return_to = get_session(conn, :user_return_to) || ~p"/orgs"
+
+ conn
+ |> delete_session(:user_return_to)
+ |> redirect(to: return_to)
+
+ {:error, _changeset} ->
+ # Shouldn't happen, but handle gracefully
+ form = Phoenix.Component.to_form(%{"totp_code" => totp_code}, as: "user")
+
+ conn
+ |> put_flash(:error, "Failed to grant sudo mode. Please try again.")
+ |> render(:verify, form: form)
+ end
+
+ {:error, :recovery_code_not_allowed} ->
+ form = Phoenix.Component.to_form(%{"totp_code" => totp_code}, as: "user")
+
+ conn
+ |> put_flash(
+ :error,
+ "Recovery codes are not allowed for sudo mode verification. Please use your authenticator app."
+ )
+ |> render(:verify, form: form)
+
+ {:error, :invalid_code} ->
+ form = Phoenix.Component.to_form(%{"totp_code" => totp_code}, as: "user")
+
+ conn
+ |> put_flash(:error, "Invalid authentication code. Please try again.")
+ |> render(:verify, form: form)
+ end
+ end
+
+ def create(conn, _params) do
+ # Handle missing totp_code parameter
+ form = Phoenix.Component.to_form(%{}, as: "user")
+ render(conn, :verify, form: form)
+ end
+end
diff --git a/lib/towerops_web/controllers/user_sudo_html.ex b/lib/towerops_web/controllers/user_sudo_html.ex
new file mode 100644
index 00000000..161ff7a6
--- /dev/null
+++ b/lib/towerops_web/controllers/user_sudo_html.ex
@@ -0,0 +1,7 @@
+defmodule ToweropsWeb.UserSudoHTML do
+ @moduledoc false
+
+ use ToweropsWeb, :html
+
+ embed_templates "user_sudo_html/*"
+end
diff --git a/lib/towerops_web/controllers/user_sudo_html/verify.html.heex b/lib/towerops_web/controllers/user_sudo_html/verify.html.heex
new file mode 100644
index 00000000..2de6a728
--- /dev/null
+++ b/lib/towerops_web/controllers/user_sudo_html/verify.html.heex
@@ -0,0 +1,47 @@
+
+
+
+ <.header>
+ Re-authenticate
+ <:subtitle>
+ Enter the 6-digit code from your authenticator app to continue
+
+
+
+
+
+ <.form :let={f} for={@form} as={:user} id="sudo_verify_form" action={~p"/users/sudo/verify"}>
+ <.input
+ field={f[:totp_code]}
+ type="text"
+ label="Authentication Code"
+ placeholder="000000"
+ autocomplete="one-time-code"
+ inputmode="numeric"
+ required
+ phx-mounted={JS.focus()}
+ />
+
+
+ Enter the 6-digit code from your authenticator app. Recovery codes are not allowed for sensitive operations.
+
+
+
+ <.button class="w-full" variant="primary">
+ Verify →
+
+
+
+
+
+ <.link
+ href={~p"/users/log-out"}
+ method="delete"
+ class="text-sm font-semibold text-gray-600 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
+ >
+ ← Log out instead
+
+
+
+
+
diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex
index 1ebc63b5..d381ff7d 100644
--- a/lib/towerops_web/router.ex
+++ b/lib/towerops_web/router.ex
@@ -185,6 +185,14 @@ defmodule ToweropsWeb.Router do
live "/users/reset-password/:token", UserResetPasswordLive, :edit
end
+ # Sudo mode verification routes
+ scope "/", ToweropsWeb do
+ pipe_through [:browser, :require_authenticated_user]
+
+ get "/users/sudo/verify", UserSudoController, :verify
+ post "/users/sudo/verify", UserSudoController, :create
+ end
+
## Admin routes (superuser only)
scope "/", ToweropsWeb do
diff --git a/lib/towerops_web/user_auth.ex b/lib/towerops_web/user_auth.ex
index fc709e3a..8777d6bb 100644
--- a/lib/towerops_web/user_auth.ex
+++ b/lib/towerops_web/user_auth.ex
@@ -372,6 +372,7 @@ defmodule ToweropsWeb.UserAuth do
"/users/register",
"/users/reset-password",
"/users/confirm",
+ "/users/sudo",
"/docs/api",
"/privacy",
"/terms",
diff --git a/test/towerops_web/controllers/user_sudo_controller_test.exs b/test/towerops_web/controllers/user_sudo_controller_test.exs
new file mode 100644
index 00000000..ff5e692d
--- /dev/null
+++ b/test/towerops_web/controllers/user_sudo_controller_test.exs
@@ -0,0 +1,165 @@
+defmodule ToweropsWeb.UserSudoControllerTest do
+ use ToweropsWeb.ConnCase, async: true
+
+ import Towerops.AccountsFixtures
+
+ alias Towerops.Accounts
+
+ setup do
+ # Create user with TOTP enabled (required for sudo mode)
+ user = user_fixture(enable_totp: true)
+ %{user: user}
+ end
+
+ describe "GET /users/sudo/verify" do
+ test "renders sudo verification page for authenticated user", %{conn: conn, user: user} do
+ conn =
+ conn
+ |> log_in_user(user)
+ |> get(~p"/users/sudo/verify")
+
+ response = html_response(conn, 200)
+ assert response =~ "Re-authenticate"
+ assert response =~ "Enter the 6-digit code from your authenticator app"
+ end
+
+ test "redirects unauthenticated users to login", %{conn: conn} do
+ conn = get(conn, ~p"/users/sudo/verify")
+ assert redirected_to(conn) == ~p"/users/log-in"
+ assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You must log in to access this page."
+ end
+
+ test "preserves return_to path from session", %{conn: conn, user: user} do
+ conn =
+ conn
+ |> log_in_user(user)
+ |> init_test_session(user_return_to: "/users/settings")
+ |> get(~p"/users/sudo/verify")
+
+ assert html_response(conn, 200) =~ "Re-authenticate"
+ assert get_session(conn, :user_return_to) == "/users/settings"
+ end
+ end
+
+ describe "POST /users/sudo/verify" do
+ test "grants sudo mode and redirects on valid TOTP code", %{conn: conn, user: user} do
+ # Get valid TOTP code for user
+ code = NimbleTOTP.verification_code(user.totp_secret)
+
+ conn =
+ conn
+ |> log_in_user(user)
+ |> init_test_session(user_return_to: "/users/settings")
+ |> post(~p"/users/sudo/verify", %{"user" => %{"totp_code" => code}})
+
+ # Should redirect to return_to path (implies sudo mode was granted)
+ assert redirected_to(conn) == "/users/settings"
+
+ # Should clear return_to from session
+ assert get_session(conn, :user_return_to) == nil
+
+ # Should update last_sudo_at timestamp
+ updated_user = Accounts.get_user!(user.id)
+ assert updated_user.last_sudo_at
+ assert DateTime.diff(updated_user.last_sudo_at, DateTime.utc_now(), :second) <= 1
+ end
+
+ test "grants sudo mode and redirects to /orgs if no return_to", %{conn: conn, user: user} do
+ code = NimbleTOTP.verification_code(user.totp_secret)
+
+ conn =
+ conn
+ |> log_in_user(user)
+ |> post(~p"/users/sudo/verify", %{"user" => %{"totp_code" => code}})
+
+ # Should redirect to /orgs (implies sudo mode was granted)
+ assert redirected_to(conn) == ~p"/orgs"
+
+ # Should update last_sudo_at timestamp
+ updated_user = Accounts.get_user!(user.id)
+ assert updated_user.last_sudo_at
+ assert DateTime.diff(updated_user.last_sudo_at, DateTime.utc_now(), :second) <= 1
+ end
+
+ test "shows error on invalid TOTP code", %{conn: conn, user: user} do
+ conn =
+ conn
+ |> log_in_user(user)
+ |> post(~p"/users/sudo/verify", %{"user" => %{"totp_code" => "000000"}})
+
+ response = html_response(conn, 200)
+ assert response =~ "Invalid authentication code"
+ assert response =~ "Re-authenticate"
+
+ # Should not grant sudo mode
+ user = Accounts.get_user!(user.id)
+ refute Accounts.sudo_mode?(user)
+ end
+
+ test "shows error when recovery code is attempted", %{conn: conn, user: user} do
+ conn =
+ conn
+ |> log_in_user(user)
+ |> post(~p"/users/sudo/verify", %{"user" => %{"totp_code" => "ABCD-EFGH"}})
+
+ response = html_response(conn, 200)
+ assert response =~ "Recovery codes are not allowed for sudo mode verification"
+ assert response =~ "Re-authenticate"
+
+ # Should not grant sudo mode
+ user = Accounts.get_user!(user.id)
+ refute Accounts.sudo_mode?(user)
+ end
+
+ test "shows error when TOTP code is too long", %{conn: conn, user: user} do
+ conn =
+ conn
+ |> log_in_user(user)
+ |> post(~p"/users/sudo/verify", %{"user" => %{"totp_code" => "1234567"}})
+
+ response = html_response(conn, 200)
+ assert response =~ "Recovery codes are not allowed for sudo mode verification"
+ assert response =~ "Re-authenticate"
+ end
+
+ test "shows error when TOTP code contains letters", %{conn: conn, user: user} do
+ conn =
+ conn
+ |> log_in_user(user)
+ |> post(~p"/users/sudo/verify", %{"user" => %{"totp_code" => "12AB34"}})
+
+ response = html_response(conn, 200)
+ assert response =~ "Recovery codes are not allowed for sudo mode verification"
+ assert response =~ "Re-authenticate"
+ end
+
+ test "redirects unauthenticated users to login", %{conn: conn} do
+ conn = post(conn, ~p"/users/sudo/verify", %{"user" => %{"totp_code" => "123456"}})
+ assert redirected_to(conn) == ~p"/users/log-in"
+ assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You must log in to access this page."
+ end
+
+ test "preserves return_to path on verification failure", %{conn: conn, user: user} do
+ conn =
+ conn
+ |> log_in_user(user)
+ |> init_test_session(user_return_to: "/users/settings")
+ |> post(~p"/users/sudo/verify", %{"user" => %{"totp_code" => "000000"}})
+
+ assert html_response(conn, 200) =~ "Invalid authentication code"
+ # return_to should be preserved for next attempt
+ assert get_session(conn, :user_return_to) == "/users/settings"
+ end
+
+ test "handles missing TOTP code parameter", %{conn: conn, user: user} do
+ conn =
+ conn
+ |> log_in_user(user)
+ |> post(~p"/users/sudo/verify", %{"user" => %{}})
+
+ response = html_response(conn, 200)
+ assert response =~ "Re-authenticate"
+ # Should render form again (no crash)
+ end
+ end
+end