diff --git a/lib/towerops_web/components/layouts.ex b/lib/towerops_web/components/layouts.ex
index 0ce8931a..0e751b2b 100644
--- a/lib/towerops_web/components/layouts.ex
+++ b/lib/towerops_web/components/layouts.ex
@@ -336,7 +336,7 @@ defmodule ToweropsWeb.Layouts do
<.link
role="menuitem"
- navigate={~p"/account/my-data"}
+ navigate={~p"/users/my-data"}
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus:outline-hidden dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white"
phx-click={JS.hide(to: "#org-menu")}
>
diff --git a/lib/towerops_web/controllers/user_session_controller.ex b/lib/towerops_web/controllers/user_session_controller.ex
index c6f789be..1e029565 100644
--- a/lib/towerops_web/controllers/user_session_controller.ex
+++ b/lib/towerops_web/controllers/user_session_controller.ex
@@ -36,7 +36,19 @@ defmodule ToweropsWeb.UserSessionController do
# email + password login
def create(conn, %{"user" => %{"email" => email, "password" => password} = user_params}) do
if user = Accounts.get_user_by_email_and_password(email, password) do
- UserAuth.log_in_user(conn, user, user_params)
+ # Check if user has TOTP enabled - require verification before creating session
+ if Accounts.totp_enabled?(user) do
+ # Store pending auth state and redirect to TOTP verification
+ remember_me = Map.get(user_params, "remember_me") == "true"
+
+ conn
+ |> put_session(:pending_totp_user_id, user.id)
+ |> put_session(:pending_totp_remember_me, remember_me)
+ |> redirect(to: ~p"/users/log-in/totp")
+ else
+ # No TOTP, proceed with normal login
+ UserAuth.log_in_user(conn, user, user_params)
+ end
else
form = Phoenix.Component.to_form(user_params, as: "user")
@@ -97,4 +109,69 @@ defmodule ToweropsWeb.UserSessionController do
|> put_flash(:info, "Logged out successfully.")
|> UserAuth.log_out_user()
end
+
+ def totp_verification_form(conn, _params) do
+ case get_session(conn, :pending_totp_user_id) do
+ nil ->
+ conn
+ |> put_flash(:error, "Please log in first.")
+ |> redirect(to: ~p"/users/log-in")
+
+ _user_id ->
+ form = Phoenix.Component.to_form(%{}, as: "user")
+ render(conn, :totp, form: form)
+ end
+ end
+
+ def verify_totp(conn, %{"user" => %{"totp_code" => code}}) do
+ case get_session(conn, :pending_totp_user_id) do
+ nil ->
+ redirect_to_login(conn)
+
+ user_id ->
+ handle_totp_verification(conn, user_id, code)
+ end
+ end
+
+ defp redirect_to_login(conn) do
+ conn
+ |> put_flash(:error, "Please log in first.")
+ |> redirect(to: ~p"/users/log-in")
+ end
+
+ defp handle_totp_verification(conn, user_id, code) do
+ user = Accounts.get_user!(user_id)
+
+ case Accounts.verify_user_totp(user, code) do
+ {:ok, user} ->
+ complete_totp_login(conn, user)
+
+ {:error, _reason} ->
+ render_totp_error(conn, code)
+ end
+ end
+
+ defp complete_totp_login(conn, user) do
+ remember_me = get_session(conn, :pending_totp_remember_me)
+
+ user_params =
+ if remember_me do
+ %{"remember_me" => "true"}
+ else
+ %{}
+ end
+
+ conn
+ |> delete_session(:pending_totp_user_id)
+ |> delete_session(:pending_totp_remember_me)
+ |> UserAuth.log_in_user(user, user_params)
+ end
+
+ defp render_totp_error(conn, code) do
+ form = Phoenix.Component.to_form(%{"totp_code" => code}, as: "user")
+
+ conn
+ |> put_flash(:error, "Invalid authentication code. Please try again.")
+ |> render(:totp, form: form)
+ end
end
diff --git a/lib/towerops_web/controllers/user_session_html/totp.html.heex b/lib/towerops_web/controllers/user_session_html/totp.html.heex
new file mode 100644
index 00000000..65672f5b
--- /dev/null
+++ b/lib/towerops_web/controllers/user_session_html/totp.html.heex
@@ -0,0 +1,43 @@
+
+
+
+ <.header>
+ Two-Factor Authentication
+ <:subtitle>
+ Enter the 6-digit code from your authenticator app
+
+
+
+
+
+ <.form :let={f} for={@form} as={:user} id="totp_form" action={~p"/users/log-in/totp"}>
+ <.input
+ field={f[:totp_code]}
+ type="text"
+ label="Authentication Code"
+ placeholder="000000"
+ autocomplete="one-time-code"
+ inputmode="numeric"
+ pattern="[0-9]{6}"
+ maxlength="6"
+ required
+ phx-mounted={JS.focus()}
+ />
+
+ <.button class="w-full" variant="primary">
+ Verify →
+
+
+
+
+
+ <.link
+ href={~p"/users/log-in"}
+ class="text-sm font-semibold text-gray-600 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
+ >
+ ← Back to login
+
+
+
+
+
diff --git a/lib/towerops_web/live/account_live/my_data.ex b/lib/towerops_web/live/account_live/my_data.ex
index 79c06315..2aac0632 100644
--- a/lib/towerops_web/live/account_live/my_data.ex
+++ b/lib/towerops_web/live/account_live/my_data.ex
@@ -138,7 +138,7 @@ defmodule ToweropsWeb.AccountLive.MyData do
<.link
- navigate={~p"/account/my-data"}
+ navigate={~p"/users/my-data"}
class="text-indigo-600 dark:text-indigo-400"
>
My Data
diff --git a/lib/towerops_web/live/user_settings_live.ex b/lib/towerops_web/live/user_settings_live.ex
index c17db543..e562808c 100644
--- a/lib/towerops_web/live/user_settings_live.ex
+++ b/lib/towerops_web/live/user_settings_live.ex
@@ -528,7 +528,7 @@ defmodule ToweropsWeb.UserSettingsLive do
<.link
- navigate={~p"/account/my-data"}
+ navigate={~p"/users/my-data"}
class="text-gray-500 hover:text-indigo-600 dark:text-gray-400 dark:hover:text-indigo-400"
>
My Data
diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex
index 940c7335..3a3e694b 100644
--- a/lib/towerops_web/router.ex
+++ b/lib/towerops_web/router.ex
@@ -175,6 +175,8 @@ defmodule ToweropsWeb.Router do
pipe_through [:browser]
get "/users/log-in", UserSessionController, :new
+ get "/users/log-in/totp", UserSessionController, :totp_verification_form
+ post "/users/log-in/totp", UserSessionController, :verify_totp
get "/users/log-in/:token", UserSessionController, :confirm
post "/users/log-in", UserSessionController, :create
delete "/users/log-out", UserSessionController, :delete
diff --git a/test/towerops/organizations/invitation_test.exs b/test/towerops/organizations/invitation_test.exs
index 0c85fae2..5e64764d 100644
--- a/test/towerops/organizations/invitation_test.exs
+++ b/test/towerops/organizations/invitation_test.exs
@@ -35,7 +35,7 @@ defmodule Towerops.Organizations.InvitationTest do
assert %{email: ["can't be blank"]} = errors_on(changeset)
end
- test "requires role" do
+ test "defaults role to :member when not provided" do
attrs = %{
email: "test@example.com",
organization_id: Ecto.UUID.generate(),
@@ -44,8 +44,8 @@ defmodule Towerops.Organizations.InvitationTest do
changeset = Invitation.changeset(%Invitation{}, attrs)
- refute changeset.valid?
- assert %{role: ["can't be blank"]} = errors_on(changeset)
+ assert changeset.valid?
+ assert Ecto.Changeset.get_field(changeset, :role) == :member
end
test "requires organization_id" do
diff --git a/test/towerops_web/controllers/user_session_controller_test.exs b/test/towerops_web/controllers/user_session_controller_test.exs
index a6586599..c2f04913 100644
--- a/test/towerops_web/controllers/user_session_controller_test.exs
+++ b/test/towerops_web/controllers/user_session_controller_test.exs
@@ -7,7 +7,11 @@ defmodule ToweropsWeb.UserSessionControllerTest do
setup do
# Create users without TOTP for login flow tests (they'll be prompted to enroll)
- %{unconfirmed_user: unconfirmed_user_fixture(), user: user_fixture(enable_totp: false)}
+ %{
+ unconfirmed_user: unconfirmed_user_fixture(),
+ user: user_fixture(enable_totp: false),
+ user_with_totp: user_fixture(enable_totp: true)
+ }
end
describe "GET /users/log-in" do
@@ -110,6 +114,106 @@ defmodule ToweropsWeb.UserSessionControllerTest do
assert response =~ "Log in"
assert response =~ "Invalid email or password"
end
+
+ test "redirects to TOTP verification when user has TOTP enabled", %{conn: conn, user_with_totp: user} do
+ conn =
+ post(conn, ~p"/users/log-in", %{
+ "user" => %{"email" => user.email, "password" => valid_user_password()}
+ })
+
+ # Should NOT create session yet
+ refute get_session(conn, :user_token)
+ # Should store pending auth state
+ assert get_session(conn, :pending_totp_user_id) == user.id
+ # Should redirect to TOTP verification
+ assert redirected_to(conn) == ~p"/users/log-in/totp"
+ end
+
+ test "does not create session if password is wrong even for TOTP users", %{
+ conn: conn,
+ user_with_totp: user
+ } do
+ conn =
+ post(conn, ~p"/users/log-in", %{
+ "user" => %{"email" => user.email, "password" => "wrong_password"}
+ })
+
+ response = html_response(conn, 200)
+ assert response =~ "Invalid email or password"
+ refute get_session(conn, :user_token)
+ refute get_session(conn, :pending_totp_user_id)
+ end
+ end
+
+ describe "GET /users/log-in/totp" do
+ test "renders TOTP verification page when pending auth exists", %{conn: conn, user_with_totp: user} do
+ conn =
+ conn
+ |> init_test_session(pending_totp_user_id: user.id)
+ |> get(~p"/users/log-in/totp")
+
+ response = html_response(conn, 200)
+ assert response =~ "Two-Factor Authentication"
+ assert response =~ "Enter the 6-digit code from your authenticator app"
+ end
+
+ test "redirects to login when no pending auth", %{conn: conn} do
+ conn = get(conn, ~p"/users/log-in/totp")
+
+ assert redirected_to(conn) == ~p"/users/log-in"
+ assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Please log in first"
+ end
+ end
+
+ describe "POST /users/log-in/totp" do
+ test "logs in with valid TOTP code", %{conn: conn, user_with_totp: user} do
+ # Generate valid TOTP code
+ code = NimbleTOTP.verification_code(user.totp_secret)
+
+ conn =
+ conn
+ |> init_test_session(pending_totp_user_id: user.id)
+ |> post(~p"/users/log-in/totp", %{"user" => %{"totp_code" => code}})
+
+ # Should create session
+ assert get_session(conn, :user_token)
+ # Should clear pending state
+ refute get_session(conn, :pending_totp_user_id)
+ # Should redirect to orgs (user has no organizations)
+ assert redirected_to(conn) == ~p"/orgs"
+ end
+
+ test "rejects invalid TOTP code", %{conn: conn, user_with_totp: user} do
+ conn =
+ conn
+ |> init_test_session(pending_totp_user_id: user.id)
+ |> post(~p"/users/log-in/totp", %{"user" => %{"totp_code" => "000000"}})
+
+ response = html_response(conn, 200)
+ assert response =~ "Invalid authentication code"
+ # Should NOT create session
+ refute get_session(conn, :user_token)
+ # Should keep pending state
+ assert get_session(conn, :pending_totp_user_id) == user.id
+ end
+
+ test "redirects to login when no pending auth", %{conn: conn} do
+ conn = post(conn, ~p"/users/log-in/totp", %{"user" => %{"totp_code" => "123456"}})
+
+ assert redirected_to(conn) == ~p"/users/log-in"
+ assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Please log in first"
+ end
+
+ test "respects remember_me from initial login", %{conn: conn, user_with_totp: user} do
+ code = NimbleTOTP.verification_code(user.totp_secret)
+
+ conn =
+ conn
+ |> init_test_session(pending_totp_user_id: user.id, pending_totp_remember_me: true)
+ |> post(~p"/users/log-in/totp", %{"user" => %{"totp_code" => code}})
+
+ assert conn.resp_cookies["_towerops_web_user_remember_me"]
+ end
end
describe "POST /users/log-in - magic link" do
diff --git a/test/towerops_web/live/my_data_live_test.exs b/test/towerops_web/live/my_data_live_test.exs
index 122fc874..ad098644 100644
--- a/test/towerops_web/live/my_data_live_test.exs
+++ b/test/towerops_web/live/my_data_live_test.exs
@@ -8,7 +8,7 @@ defmodule ToweropsWeb.AccountLive.MyDataTest do
describe "My Data page" do
test "renders for organization owner without additional data", %{conn: conn} do
# register_and_log_in_user creates a user with a Personal org (as owner)
- {:ok, _view, html} = live(conn, ~p"/account/my-data")
+ {:ok, _view, html} = live(conn, ~p"/users/my-data")
assert html =~ "My Data"
assert html =~ "Profile Information"
@@ -44,7 +44,7 @@ defmodule ToweropsWeb.AccountLive.MyDataTest do
message: "Device is down"
})
- {:ok, _view, html} = live(conn, ~p"/account/my-data")
+ {:ok, _view, html} = live(conn, ~p"/users/my-data")
assert html =~ "My Data"
assert html =~ "Profile Information"