diff --git a/lib/bones73k/accounts.ex b/lib/bones73k/accounts.ex index da69fefa..6e2d9a1d 100644 --- a/lib/bones73k/accounts.ex +++ b/lib/bones73k/accounts.ex @@ -10,6 +10,19 @@ defmodule Bones73k.Accounts do ## Database getters + @doc """ + Returns the list of users. + + ## Examples + + iex> list_users() + [%User{}, ...] + + """ + def list_users do + Repo.all(User) + end + @doc """ Gets a user by email. @@ -60,6 +73,22 @@ defmodule Bones73k.Accounts do """ def get_user!(id), do: Repo.get!(User, id) + @doc """ + Gets a single user. + + Returns nil if the User does not exist. + + ## Examples + + iex> get_user(123) + %User{} + + iex> get_user(456) + nil + + """ + def get_user(id), do: Repo.get(User, id) + ## User registration @doc """ @@ -81,22 +110,11 @@ defmodule Bones73k.Accounts do end @doc """ - Registers an admin. - - ## Examples - - iex> register_admin(%{field: value}) - {:ok, %User{}} - - iex> register_admin(%{field: bad_value}) - {:error, %Ecto.Changeset{}} - + Returns role for user registration. First user to register gets :admin role. + Following users get :user role. Used by registration controller/liveview. + Admins can choose role for new users they create, and modify other uses. """ - def register_admin(attrs) do - %User{} - |> User.admin_registration_changeset(attrs) - |> Repo.insert() - end + def registration_role, do: (Repo.exists?(User) && :user) || :admin def logout_user(%User{} = user) do # Delete all user tokens diff --git a/lib/bones73k/accounts/user.ex b/lib/bones73k/accounts/user.ex index 8921f11f..cbcb7860 100644 --- a/lib/bones73k/accounts/user.ex +++ b/lib/bones73k/accounts/user.ex @@ -5,9 +5,14 @@ defmodule Bones73k.Accounts.User do defenum(RolesEnum, :role, [ :user, + :manager, :admin ]) + @max_email 254 + @min_password 6 + @max_password 80 + @derive {Inspect, except: [:password]} schema "users" do field :email, :string @@ -19,6 +24,10 @@ defmodule Bones73k.Accounts.User do timestamps() end + def max_email, do: @max_email + def min_password, do: @min_password + def max_password, do: @max_password + @doc """ A user changeset for registration. @@ -26,53 +35,71 @@ defmodule Bones73k.Accounts.User do Otherwise databases may truncate the email without warnings, which could lead to unpredictable or insecure behaviour. Long passwords may also be very expensive to hash for certain algorithms. + + ## 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. If password hashing is not needed and clearing the + password field is not desired (like when using this changeset for + validations on a LiveView form), this option can be set to `false`. + Defaults to `true`. """ - def registration_changeset(user, attrs) do + def registration_changeset(user, attrs, opts \\ []) do user - |> cast(attrs, [:email, :password]) + |> cast(attrs, [:email, :password, :role]) + |> validate_role() |> validate_email() - |> validate_password() + |> validate_password(opts) end - @doc """ - A user changeset for registering admins. - """ - def admin_registration_changeset(user, attrs) do - user - |> registration_changeset(attrs) - |> prepare_changes(&set_admin_role/1) + defp role_validator(:role, role) do + (RolesEnum.valid_value?(role) && []) || [role: "invalid user role"] + end + + defp validate_role(changeset) do + changeset + |> validate_required([:role]) + |> validate_change(:role, &role_validator/2) + end + + defp validate_email_format(changeset) do + r_email = ~r/^[\w.!#$%&’*+\-\/=?\^`{|}~]+@[a-z0-9-]+(\.[a-z0-9-]+)*$/i + + changeset + |> validate_required([:email]) + |> validate_format(:email, r_email, message: "must be a valid email address") + |> validate_length(:email, max: @max_email) end defp validate_email(changeset) do changeset - |> validate_required([:email]) - |> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces") - |> validate_length(:email, max: 160) + |> validate_email_format() |> unsafe_validate_unique(:email, Bones73k.Repo) |> unique_constraint(:email) end - defp validate_password(changeset) do + defp validate_password(changeset, opts) do changeset |> validate_required([:password]) - |> validate_length(:password, min: 12, max: 80) + |> validate_length(:password, min: @min_password, max: @max_password) # |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character") # |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character") # |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character") - |> prepare_changes(&hash_password/1) + |> maybe_hash_password(opts) end - defp hash_password(changeset) do + defp maybe_hash_password(changeset, opts) do + hash_password? = Keyword.get(opts, :hash_password, true) password = get_change(changeset, :password) - changeset - |> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password)) - |> delete_change(:password) - end - - defp set_admin_role(changeset) do - changeset - |> put_change(:role, :admin) + if hash_password? && password && changeset.valid? do + changeset + |> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password)) + |> delete_change(:password) + else + changeset + end end @doc """ @@ -93,11 +120,11 @@ defmodule Bones73k.Accounts.User do @doc """ A user changeset for changing the password. """ - def password_changeset(user, attrs) do + def password_changeset(user, attrs, opts \\ []) do user |> cast(attrs, [:password]) |> validate_confirmation(:password, message: "does not match password") - |> validate_password() + |> validate_password(opts) end @doc """ diff --git a/lib/bones73k_web/controllers/user_registration_controller.ex b/lib/bones73k_web/controllers/user_registration_controller.ex index 12ba86b5..58e9c83b 100644 --- a/lib/bones73k_web/controllers/user_registration_controller.ex +++ b/lib/bones73k_web/controllers/user_registration_controller.ex @@ -6,12 +6,15 @@ defmodule Bones73kWeb.UserRegistrationController do alias Bones73kWeb.UserAuth def new(conn, _params) do - changeset = Accounts.change_user_registration(%User{}) + changeset = Accounts.change_user_registration(%User{}, %{role: Accounts.registration_role()}) render(conn, "new.html", changeset: changeset) end def create(conn, %{"user" => user_params}) do - case Accounts.register_user(user_params) do + user_params + |> Map.put_new("role", Accounts.registration_role()) + |> Accounts.register_user() + |> case do {:ok, user} -> %Bamboo.Email{} = Accounts.deliver_user_confirmation_instructions( diff --git a/lib/bones73k_web/templates/user_registration/new.html.eex b/lib/bones73k_web/templates/user_registration/new.html.eex index 12b483bf..bceb8254 100644 --- a/lib/bones73k_web/templates/user_registration/new.html.eex +++ b/lib/bones73k_web/templates/user_registration/new.html.eex @@ -1,22 +1,37 @@ -

Register

+
+
-<%= form_for @changeset, Routes.user_registration_path(@conn, :create), fn f -> %> +

Register

+

Registration gains additional features, like remembering your song request history.

+ +<%= form_for @changeset, Routes.user_registration_path(@conn, :create), form_opts(@changeset, novalidate: true), fn f -> %> <%= if @changeset.action do %> -
-

Oops, something went wrong! Please check the errors below.

+ <% end %> - <%= label f, :email %> - <%= email_input f, :email, required: true %> - <%= error_tag f, :email %> + <%= label f, :email, class: "form-label" %> +
+ + <%= icon_div @conn, "bi-at", [class: "icon fs-5"] %> + + <%= email_input f, :email, class: error_class(f, :email, "form-control"), required: true %> +
+ <%= error_tag f, :email, class: "d-block mt-n3 mb-3" %> - <%= label f, :password %> - <%= password_input f, :password, required: true %> - <%= error_tag f, :password %> + <%= label f, :password, class: "form-label" %> +
+ + <%= icon_div @conn, "bi-key", [class: "icon fs-5"] %> + + <%= password_input f, :password, class: "form-control", required: true %> +
+ <%= error_tag f, :password, class: "d-block mt-n3 mb-3" %> -
- <%= submit "Register" %> +
+ <%= submit "Register", class: "btn btn-primary" %>
<% end %> @@ -25,6 +40,12 @@ <%= link "Forgot your password?", to: Routes.user_reset_password_path(@conn, :new) %>

+
+
+ + + +

Lorem

diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 5da03cd6..6ab43f8b 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -9,26 +9,30 @@ # # We recommend using the bang functions (`insert!`, `update!` # and so on) as they will fail if something goes wrong. +alias Bones73k.Accounts {:ok, admin} = - Bones73k.Accounts.register_admin(%{ + Accounts.register_user(%{ email: "admin@company.com", password: "123456789abc", - password_confirmation: "123456789abc" + password_confirmation: "123456789abc", + role: Accounts.registration_role() }) {:ok, user_1} = - Bones73k.Accounts.register_user(%{ + Accounts.register_user(%{ email: "user1@company.com", password: "123456789abc", - password_confirmation: "123456789abc" + password_confirmation: "123456789abc", + role: Accounts.registration_role() }) {:ok, user_2} = - Bones73k.Accounts.register_user(%{ + Accounts.register_user(%{ email: "user2@company.com", password: "123456789abc", - password_confirmation: "123456789abc" + password_confirmation: "123456789abc", + role: Accounts.registration_role() }) Enum.each(1..10, fn i ->