use password for user regi

This commit is contained in:
Graham McIntire 2026-01-21 09:13:08 -06:00
parent 1d45699240
commit 3abd70e133
No known key found for this signature in database
13 changed files with 189 additions and 191 deletions

View file

@ -84,7 +84,20 @@ defmodule Towerops.Accounts do
## User registration
@doc """
Registers a user.
Returns an `%Ecto.Changeset{}` for tracking user registration changes.
## Examples
iex> change_user_registration(user)
%Ecto.Changeset{data: %User{}}
"""
def change_user_registration(%User{} = user, attrs \\ %{}) do
User.registration_changeset(user, attrs, hash_password: false, validate_unique: false)
end
@doc """
Registers a user with email and password.
## Examples
@ -97,12 +110,13 @@ defmodule Towerops.Accounts do
"""
def register_user(attrs) do
%User{}
|> User.email_changeset(attrs)
|> User.registration_changeset(attrs)
|> Ecto.Changeset.put_change(:confirmed_at, DateTime.utc_now(:second))
|> Repo.insert()
end
@doc """
Registers a user and creates a default organization.
Registers a user with email and password, and creates a default organization.
## Examples
@ -115,7 +129,10 @@ defmodule Towerops.Accounts do
"""
@dialyzer {:nowarn_function, register_user_with_organization: 1}
def register_user_with_organization(attrs) do
user_changeset = User.email_changeset(%User{}, attrs)
user_changeset =
%User{}
|> User.registration_changeset(attrs)
|> Ecto.Changeset.put_change(:confirmed_at, DateTime.utc_now(:second))
multi = Ecto.Multi.new()
multi = Ecto.Multi.insert(multi, :user, user_changeset)

View file

@ -106,6 +106,42 @@ defmodule Towerops.Accounts.User do
|> validate_length(:timezone, max: 100)
end
@doc """
A user changeset for registration with email and password.
## Options
* `:hash_password` - Hashes the password so it can be stored securely
in the database and ensures the password field is cleared to prevent
leaks in the logs. Defaults to `true`.
* `:validate_unique` - Set to false if you don't want to validate the
uniqueness of the email, useful when displaying live validations.
Defaults to `true`.
"""
def registration_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:email, :password])
|> validate_email_for_registration(opts)
|> validate_password(opts)
end
defp validate_email_for_registration(changeset, opts) do
changeset =
changeset
|> validate_required([:email])
|> validate_format(:email, ~r/^[^@,;\s]+@[^@,;\s]+$/, message: "must have the @ sign and no spaces")
|> validate_length(:email, max: 160)
if Keyword.get(opts, :validate_unique, true) do
changeset
|> unsafe_validate_unique(:email, Towerops.Repo)
|> unique_constraint(:email)
else
changeset
end
end
@doc """
A user changeset for changing the password.

View file

@ -5,30 +5,22 @@ defmodule ToweropsWeb.UserRegistrationController do
alias Towerops.Accounts
alias Towerops.Accounts.User
alias ToweropsWeb.UserAuth
def new(conn, _params) do
changeset = Accounts.change_user_email(%User{})
render(conn, :new, changeset: changeset)
changeset = Accounts.change_user_registration(%User{})
render(conn, :new, form: Phoenix.Component.to_form(changeset, as: "user"))
end
def create(conn, %{"user" => user_params}) do
case Accounts.register_user_with_organization(user_params) do
{:ok, user} ->
{:ok, _} =
Accounts.deliver_login_instructions(
user,
&url(~p"/users/log-in/#{&1}")
)
conn
|> put_flash(
:info,
"An email was sent to #{user.email}, please access it to confirm your account."
)
|> redirect(to: ~p"/users/log-in")
|> put_flash(:info, "Account created successfully.")
|> UserAuth.log_in_user(user)
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, :new, changeset: changeset)
render(conn, :new, form: Phoenix.Component.to_form(changeset, as: "user"))
end
end
end

View file

@ -17,7 +17,7 @@
</div>
<div class="mt-8">
<.form :let={f} for={@changeset} action={~p"/users/register"}>
<.form :let={f} for={@form} action={~p"/users/register"}>
<.input
field={f[:email]}
type="email"
@ -26,6 +26,13 @@
required
phx-mounted={JS.focus()}
/>
<.input
field={f[:password]}
type="password"
label="Password"
autocomplete="new-password"
required
/>
<.button phx-disable-with="Creating account..." class="w-full" variant="primary">
Create an account

View file

@ -41,7 +41,58 @@
</div>
<div class="mt-8 space-y-6">
<!-- Passkey Login (Discoverable) -->
<!-- Password Login (Default) -->
<.form :let={f} for={@form} as={:user} id="login_form_password" action={~p"/users/log-in"}>
<.input
readonly={!!@current_scope}
field={f[:email]}
type="email"
label="Email"
autocomplete="email"
required
phx-mounted={JS.focus()}
/>
<.input
field={f[:password]}
type="password"
label="Password"
autocomplete="current-password"
required
/>
<div class="space-y-3">
<.button class="w-full" variant="primary" name={@form[:remember_me].name} value="true">
Log in <span aria-hidden="true">→</span>
</.button>
</div>
</.form>
<div class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-300 dark:border-white/10"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="bg-gray-50 px-2 text-gray-600 dark:bg-gray-950 dark:text-gray-400">
or
</span>
</div>
</div>
<!-- Alternative: Email Login Link -->
<.form :let={f} for={@form} as={:user} id="login_form_magic" action={~p"/users/log-in"}>
<.input
readonly={!!@current_scope}
field={f[:email]}
type="email"
label="Email"
autocomplete="email"
required
/>
<.button class="w-full">
Send me a login link instead
</.button>
</.form>
<!-- Passkey Login (Discoverable) -->
<div>
<div
id="passkey-discoverable-error"
@ -52,72 +103,11 @@
<button
type="button"
id="passkey-discoverable-btn"
class="w-full inline-flex items-center justify-center gap-2 rounded-lg bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
class="w-full inline-flex items-center justify-center gap-2 rounded-lg bg-gray-100 px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm hover:bg-gray-200 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-600 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-white/10 dark:text-white dark:hover:bg-white/20"
>
<.icon name="hero-key" class="h-5 w-5" /> Log in with passkey
</button>
</div>
<div class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-300 dark:border-white/10"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="bg-gray-50 px-2 text-gray-600 dark:bg-gray-950 dark:text-gray-400">
or use email
</span>
</div>
</div>
<.form :let={f} for={@form} as={:user} id="login_form_magic" action={~p"/users/log-in"}>
<.input
readonly={!!@current_scope}
field={f[:email]}
type="email"
label="Email"
autocomplete="email"
required
/>
<.button class="w-full" variant="primary">
Log in with email <span aria-hidden="true">→</span>
</.button>
</.form>
<div class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-300 dark:border-white/10"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="bg-gray-50 px-2 text-gray-600 dark:bg-gray-950 dark:text-gray-400">
or use password
</span>
</div>
</div>
<.form :let={f} for={@form} as={:user} id="login_form_password" action={~p"/users/log-in"}>
<.input
readonly={!!@current_scope}
field={f[:email]}
type="email"
label="Email"
autocomplete="email"
required
/>
<.input
field={f[:password]}
type="password"
label="Password"
autocomplete="current-password"
/>
<div class="space-y-3">
<.button class="w-full" variant="primary" name={@form[:remember_me].name} value="true">
Log in and stay logged in <span aria-hidden="true">→</span>
</.button>
<.button class="w-full">
Log in only this time
</.button>
</div>
</.form>
</div>
</div>
</Layouts.app>

View file

@ -14,7 +14,8 @@ defmodule Towerops.AccountsFixtures do
def valid_user_attributes(attrs \\ %{}) do
Enum.into(attrs, %{
email: unique_user_email()
email: unique_user_email(),
password: valid_user_password()
})
end
@ -24,19 +25,17 @@ defmodule Towerops.AccountsFixtures do
|> valid_user_attributes()
|> Accounts.register_user()
# Set confirmed_at to nil for testing unconfirmed user flows
user
|> Ecto.Changeset.change(confirmed_at: nil)
|> Towerops.Repo.update!()
end
def user_fixture(attrs \\ %{}) do
user = unconfirmed_user_fixture(attrs)
token =
extract_user_token(fn url ->
Accounts.deliver_login_instructions(user, url)
end)
{:ok, {user, _expired_tokens}} =
Accounts.login_user_by_magic_link(token)
{:ok, user} =
attrs
|> valid_user_attributes()
|> Accounts.register_user()
user
end

View file

@ -24,12 +24,12 @@ defmodule Towerops.AccountsTest do
end
test "does not return the user if the password is not valid" do
user = set_password(user_fixture())
user = user_fixture()
refute Accounts.get_user_by_email_and_password(user.email, "invalid")
end
test "returns the user if the email and password are valid" do
%{id: id} = user = set_password(user_fixture())
%{id: id} = user = user_fixture()
assert %User{id: ^id} =
Accounts.get_user_by_email_and_password(user.email, valid_user_password())
@ -50,40 +50,45 @@ defmodule Towerops.AccountsTest do
end
describe "register_user/1" do
test "requires email to be set" do
test "requires email and password to be set" do
{:error, changeset} = Accounts.register_user(%{})
assert %{email: ["can't be blank"]} = errors_on(changeset)
assert %{email: ["can't be blank"], password: ["can't be blank"]} = errors_on(changeset)
end
test "validates email when given" do
{:error, changeset} = Accounts.register_user(%{email: "not valid"})
{:error, changeset} = Accounts.register_user(%{email: "not valid", password: valid_user_password()})
assert %{email: ["must have the @ sign and no spaces"]} = errors_on(changeset)
end
test "validates maximum values for email for security" do
too_long = String.duplicate("db", 100)
{:error, changeset} = Accounts.register_user(%{email: too_long})
{:error, changeset} = Accounts.register_user(%{email: too_long, password: valid_user_password()})
assert "should be at most 160 character(s)" in errors_on(changeset).email
end
test "validates password length" do
{:error, changeset} = Accounts.register_user(%{email: unique_user_email(), password: "short"})
assert "should be at least 12 character(s)" in errors_on(changeset).password
end
test "validates email uniqueness" do
%{email: email} = user_fixture()
{:error, changeset} = Accounts.register_user(%{email: email})
{:error, changeset} = Accounts.register_user(%{email: email, password: valid_user_password()})
assert "has already been taken" in errors_on(changeset).email
# Now try with the uppercased email too, to check that email case is ignored.
{:error, changeset} = Accounts.register_user(%{email: String.upcase(email)})
{:error, changeset} = Accounts.register_user(%{email: String.upcase(email), password: valid_user_password()})
assert "has already been taken" in errors_on(changeset).email
end
test "registers users without password" do
test "registers users with email and password" do
email = unique_user_email()
{:ok, user} = Accounts.register_user(valid_user_attributes(email: email))
assert user.email == email
assert is_nil(user.hashed_password)
assert is_nil(user.confirmed_at)
assert is_binary(user.hashed_password)
assert user.confirmed_at
assert is_nil(user.password)
end
end
@ -331,29 +336,20 @@ defmodule Towerops.AccountsTest do
end
describe "login_user_by_magic_link/1" do
test "confirms user and expires tokens" do
user = unconfirmed_user_fixture()
refute user.confirmed_at
{encoded_token, hashed_token} = generate_user_magic_link_token(user)
assert {:ok, {user, [%{token: ^hashed_token}]}} =
Accounts.login_user_by_magic_link(encoded_token)
assert user.confirmed_at
end
test "returns user and (deleted) token for confirmed user" do
test "logs in confirmed user and deletes token" do
user = user_fixture()
assert user.confirmed_at
{encoded_token, _hashed_token} = generate_user_magic_link_token(user)
assert {:ok, {^user, []}} = Accounts.login_user_by_magic_link(encoded_token)
assert {:ok, {logged_in_user, []}} = Accounts.login_user_by_magic_link(encoded_token)
assert logged_in_user.id == user.id
# one time use only
assert {:error, :not_found} = Accounts.login_user_by_magic_link(encoded_token)
end
test "raises when unconfirmed user has password set" do
user = unconfirmed_user_fixture()
{1, nil} = Repo.update_all(User, set: [hashed_password: "hashed"])
refute user.confirmed_at
assert user.hashed_password
{encoded_token, _hashed_token} = generate_user_magic_link_token(user)
assert_raise RuntimeError, ~r/magic link log in is not allowed/, fn ->

View file

@ -42,11 +42,6 @@ defmodule Towerops.Alerts.AlertNotifierTest do
site_id: site.id
})
# Consume confirmation emails from user creation
assert_email_sent(subject: "Confirmation instructions")
assert_email_sent(subject: "Confirmation instructions")
assert_email_sent(subject: "Confirmation instructions")
%{owner: owner, admin: admin, member: member, organization: organization, device: device}
end
@ -100,7 +95,6 @@ defmodule Towerops.Alerts.AlertNotifierTest do
end
test "equipment_down alert includes correct information", %{
owner: owner,
device: device,
organization: organization
} do
@ -116,10 +110,9 @@ defmodule Towerops.Alerts.AlertNotifierTest do
assert length(results) == 2
# Verify email sent to owner contains all expected information
# Verify email contains all expected information (recipients verified in other tests)
assert_email_sent(fn email ->
email.to |> List.first() |> elem(1) == owner.email &&
email.subject =~ "Device Down" &&
email.subject =~ "Device Down" &&
email.text_body =~ organization.name &&
email.text_body =~ device.name &&
email.text_body =~ device.ip_address &&

View file

@ -2,7 +2,6 @@ defmodule Towerops.Monitoring.DeviceMonitorTest do
use Towerops.DataCase, async: false
import Mox
import Swoosh.TestAssertions
import Towerops.AccountsFixtures
alias Towerops.Alerts
@ -40,9 +39,6 @@ defmodule Towerops.Monitoring.DeviceMonitorTest do
check_interval_seconds: 30
})
# Consume confirmation email from user creation
assert_email_sent(subject: "Confirmation instructions")
%{device: device, site: site, organization: organization}
end

View file

@ -21,7 +21,7 @@ defmodule ToweropsWeb.UserRegistrationControllerTest do
describe "POST /users/register" do
@tag :capture_log
test "creates account but does not log in", %{conn: conn} do
test "creates account and logs in", %{conn: conn} do
email = unique_user_email()
conn =
@ -29,22 +29,33 @@ defmodule ToweropsWeb.UserRegistrationControllerTest do
"user" => valid_user_attributes(email: email)
})
refute get_session(conn, :user_token)
assert redirected_to(conn) == ~p"/users/log-in"
assert conn.assigns.flash["info"] =~
~r/An email was sent to .*, please access it to confirm your account/
assert get_session(conn, :user_token)
# New users with no organizations get redirected to /devices
assert redirected_to(conn) == ~p"/devices"
assert conn.assigns.flash["info"] =~ "Account created successfully"
end
test "render errors for invalid data", %{conn: conn} do
conn =
post(conn, ~p"/users/register", %{
"user" => %{"email" => "with spaces"}
"user" => %{"email" => "with spaces", "password" => "short"}
})
response = html_response(conn, 200)
assert response =~ "Register"
assert response =~ "must have the @ sign and no spaces"
assert response =~ "should be at least 12 character"
end
test "render errors when password is missing", %{conn: conn} do
conn =
post(conn, ~p"/users/register", %{
"user" => %{"email" => unique_user_email(), "password" => ""}
})
response = html_response(conn, 200)
assert response =~ "Register"
assert response =~ "can&#39;t be blank"
end
end
end

View file

@ -1,25 +1,29 @@
defmodule ToweropsWeb.UserRegistrationHTMLTest do
use ToweropsWeb.ConnCase, async: true
import Phoenix.Component, only: [to_form: 2]
import Phoenix.Template, only: [render_to_string: 4]
alias Towerops.Accounts
alias Towerops.Accounts.User
describe "new.html" do
test "renders registration form" do
changeset = User.email_changeset(%User{}, %{})
changeset = Accounts.change_user_registration(%User{})
form = to_form(changeset, as: "user")
html =
render_to_string(ToweropsWeb.UserRegistrationHTML, "new", "html",
flash: %{},
current_scope: nil,
changeset: changeset
form: form
)
assert html =~ "Register for an account"
assert html =~ "Already registered?"
assert html =~ "Log in"
assert html =~ "Create an account"
assert html =~ "Password"
end
end
end

View file

@ -15,7 +15,7 @@ defmodule ToweropsWeb.UserSessionControllerTest do
response = html_response(conn, 200)
assert response =~ "Log in"
assert response =~ ~p"/users/register"
assert response =~ "Log in with email"
assert response =~ "Send me a login link instead"
end
test "renders login page with email filled in (sudo mode)", %{conn: conn, user: user} do
@ -27,36 +27,15 @@ defmodule ToweropsWeb.UserSessionControllerTest do
assert html =~ "You need to reauthenticate"
refute html =~ "Register"
assert html =~ "Log in with email"
assert html =~ "Send me a login link instead"
assert html =~
~s(<input type="email" name="user[email]" id="login_form_magic_email" value="#{user.email}")
end
test "renders login page (email + password)", %{conn: conn} do
conn = get(conn, ~p"/users/log-in?mode=password")
response = html_response(conn, 200)
assert response =~ "Log in"
assert response =~ ~p"/users/register"
assert response =~ "Log in with email"
~s(<input type="email" name="user[email]" id="login_form_password_email" value="#{user.email}")
end
end
describe "GET /users/log-in/:token" do
test "logs in and confirms unconfirmed user", %{conn: conn, unconfirmed_user: user} do
token =
extract_user_token(fn url ->
Accounts.deliver_login_instructions(user, url)
end)
conn = get(conn, ~p"/users/log-in/#{token}")
assert redirected_to(conn) == ~p"/orgs"
assert get_session(conn, :user_token)
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "User confirmed successfully."
assert conn.resp_cookies["_towerops_web_user_remember_me"]
end
test "logs in confirmed user", %{conn: conn, user: user} do
test "logs in confirmed user via magic link", %{conn: conn, user: user} do
token =
extract_user_token(fn url ->
Accounts.deliver_login_instructions(user, url)
@ -80,8 +59,6 @@ defmodule ToweropsWeb.UserSessionControllerTest do
describe "POST /users/log-in - email and password" do
test "logs the user in", %{conn: conn, user: user} do
user = set_password(user)
conn =
post(conn, ~p"/users/log-in", %{
"user" => %{"email" => user.email, "password" => valid_user_password()}
@ -92,8 +69,6 @@ defmodule ToweropsWeb.UserSessionControllerTest do
end
test "logs the user in with remember me", %{conn: conn, user: user} do
user = set_password(user)
conn =
post(conn, ~p"/users/log-in", %{
"user" => %{
@ -108,8 +83,6 @@ defmodule ToweropsWeb.UserSessionControllerTest do
end
test "logs the user in with return to", %{conn: conn, user: user} do
user = set_password(user)
conn =
conn
|> init_test_session(user_return_to: "/foo/bar")
@ -125,7 +98,7 @@ defmodule ToweropsWeb.UserSessionControllerTest do
test "emits error message with invalid credentials", %{conn: conn, user: user} do
conn =
post(conn, ~p"/users/log-in?mode=password", %{
post(conn, ~p"/users/log-in", %{
"user" => %{"email" => user.email, "password" => "invalid_password"}
})
@ -146,7 +119,7 @@ defmodule ToweropsWeb.UserSessionControllerTest do
assert Towerops.Repo.get_by!(Accounts.UserToken, user_id: user.id).context == "login"
end
test "logs the user in", %{conn: conn, user: user} do
test "logs in confirmed user via magic link token", %{conn: conn, user: user} do
{token, _hashed_token} = generate_user_magic_link_token(user)
conn =
@ -158,23 +131,6 @@ defmodule ToweropsWeb.UserSessionControllerTest do
assert redirected_to(conn) == ~p"/orgs"
end
test "confirms unconfirmed user", %{conn: conn, unconfirmed_user: user} do
{token, _hashed_token} = generate_user_magic_link_token(user)
refute user.confirmed_at
conn =
post(conn, ~p"/users/log-in", %{
"user" => %{"token" => token},
"_action" => "confirmed"
})
assert get_session(conn, :user_token)
assert redirected_to(conn) == ~p"/orgs"
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "User confirmed successfully."
assert Accounts.get_user!(user.id).confirmed_at
end
test "emits error message when magic link is invalid", %{conn: conn} do
conn =
post(conn, ~p"/users/log-in", %{

View file

@ -20,8 +20,9 @@ defmodule ToweropsWeb.UserSessionHTMLTest do
assert html =~ "Log in"
assert html =~ "Sign up"
assert html =~ "Log in with passkey"
assert html =~ "or use email"
assert html =~ "or use password"
assert html =~ "Send me a login link instead"
# Password login is now the default (no longer separated by "or use password")
assert html =~ "Password"
end
test "renders reauthentication message when current_scope is present" do