user management with pagination & modal improvements

This commit is contained in:
Adam Piontek 2021-03-04 22:03:27 -05:00
parent 488a4e5195
commit 18468e3cc3
35 changed files with 2948 additions and 86 deletions

View file

@ -144,6 +144,40 @@ defmodule Bones73k.Accounts do
User.registration_changeset(user, attrs)
end
## User Management: updates, deletes
@doc """
Returns an `%Ecto.Changeset{}` for tracking user updates.
## Examples
iex> change_user_update(user)
%Ecto.Changeset{data: %User{}}
"""
def change_user_update(user, attrs \\ %{})
def change_user_update(nil, _), do: nil
def change_user_update(%User{} = user, attrs) do
User.update_changeset(user, attrs, hash_password: true)
end
# @doc """
# Returns an `%Ecto.Changeset{}` for tracking singer_name updates.
# """
# def change_singer_name_update(%User{} = user, attrs \\ %{}) do
# User.update_singer_name_changeset(user, attrs)
# end
@doc """
Updates the user given with attributes given
"""
def update_user(user, attrs) do
user
|> User.update_changeset(attrs, hash_password: true)
|> Repo.update()
end
## Settings
@doc """
@ -398,4 +432,21 @@ defmodule Bones73k.Accounts do
{:error, :user, changeset, _} -> {:error, changeset}
end
end
## Management Actions
@doc """
Deletes a user.
## Examples
iex> delete_user(user)
{:ok, %User{}}
iex> delete_user(user)
{:error, %Ecto.Changeset{}}
"""
def delete_user(nil), do: {:error, false}
def delete_user(%User{} = user), do: Repo.delete(user)
end

View file

@ -3,11 +3,13 @@ defmodule Bones73k.Accounts.User do
import Ecto.Changeset
import EctoEnum
defenum(RolesEnum, :role, [
:user,
:manager,
:admin
])
@roles [
user: "Basic user level",
manager: "Can create users, update user emails & passwords",
admin: "Can delete users and change user roles"
]
defenum(RolesEnum, :role, Keyword.keys(@roles))
@max_email 254
@min_password 6
@ -27,6 +29,7 @@ defmodule Bones73k.Accounts.User do
def max_email, do: @max_email
def min_password, do: @min_password
def max_password, do: @max_password
def roles, do: @roles
@doc """
A user changeset for registration.
@ -53,6 +56,28 @@ defmodule Bones73k.Accounts.User do
|> validate_password(opts)
end
# def update_changeset(user, attrs, opts \\ [])
# def update_changeset(user, %{"password" => ""} = attrs, _),
# do: update_changeset_no_pw(user, attrs)
# def update_changeset(user, %{password: ""} = attrs, _), do: update_changeset_no_pw(user, attrs)
def update_changeset(user, attrs, opts) do
user
|> cast(attrs, [:email, :password, :role])
|> validate_role()
|> validate_email()
|> validate_password_not_required(opts)
end
# def update_changeset_no_pw(user, attrs) do
# user
# |> cast(attrs, [:email, :role])
# |> validate_role()
# |> validate_email()
# end
defp role_validator(:role, role) do
(RolesEnum.valid_value?(role) && []) || [role: "invalid user role"]
end
@ -82,6 +107,11 @@ defmodule Bones73k.Accounts.User do
defp validate_password(changeset, opts) do
changeset
|> validate_required([:password])
|> validate_password_not_required(opts)
end
defp validate_password_not_required(changeset, opts) do
changeset
|> 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")

View file

@ -2,4 +2,6 @@ defmodule Bones73k.Repo do
use Ecto.Repo,
otp_app: :bones73k,
adapter: Ecto.Adapters.Postgres
use Scrivener, page_size: 10
end

10
lib/bones73k/util/dt.ex Normal file
View file

@ -0,0 +1,10 @@
defmodule Bones73k.Util.Dt do
@app_vars Application.get_env(:bones73k, :app_global_vars, time_zone: "America/New_York")
@time_zone @app_vars[:time_zone]
def ndt_to_local(%NaiveDateTime{} = ndt), do: Timex.to_datetime(ndt, @time_zone)
def format_dt_local(dt_local, fstr), do: Timex.format!(dt_local, fstr)
def format_ndt(%NaiveDateTime{} = ndt, fstr), do: ndt |> ndt_to_local() |> format_dt_local(fstr)
end

View file

@ -140,6 +140,20 @@ defmodule Bones73kWeb.UserAuth do
end
end
@doc """
Used for routes that require the user's email to be confirmed.
"""
def require_email_confirmed(conn, _opts) do
if conn.assigns[:current_user] |> Map.get(:confirmed_at) do
conn
else
conn
|> put_flash(:error, "You must confirm your email to access this page.")
|> redirect(to: Routes.user_confirmation_path(conn, :new))
|> halt()
end
end
@doc """
Returns the pubsub topic name for receiving notifications when a user updated
"""

View file

@ -1,9 +1,10 @@
defmodule Bones73kWeb.LiveHelpers do
import Phoenix.LiveView
import Phoenix.LiveView.Helpers
alias Bones73k.Accounts
alias Bones73k.Accounts.User
alias Bones73kWeb.UserAuth
import Phoenix.LiveView.Helpers
@doc """
Performs the {:noreply, socket} for a given socket.
@ -32,8 +33,7 @@ defmodule Bones73kWeb.LiveHelpers do
return_to: Routes.property_index_path(@socket, :index) %>
"""
def live_modal(socket, component, opts) do
path = Keyword.fetch!(opts, :return_to)
modal_opts = [id: :modal, return_to: path, component: component, opts: opts]
modal_opts = [id: :modal, component: component, opts: opts]
# dirty little workaround for elixir complaining about socket being unused
_socket = socket
live_component(socket, Bones73kWeb.ModalComponent, modal_opts)
@ -56,4 +56,14 @@ defmodule Bones73kWeb.LiveHelpers do
_ -> socket
end
end
@doc """
Copies current flash into new put_flash invocations.
To be used before a push_patch.
"""
def copy_flash(%{assigns: %{flash: flash}} = socket) do
Enum.reduce(flash, socket, fn {k, v}, acc ->
put_flash(acc, String.to_existing_atom(k), v)
end)
end
end

View file

@ -31,7 +31,8 @@ defmodule Bones73kWeb.ModalComponent do
@impl true
def handle_event("close", _, socket) do
{:noreply, push_patch(socket, to: socket.assigns.return_to)}
send(self(), {:close_modal, true})
live_noreply(socket)
end
@impl true

View file

@ -28,8 +28,10 @@ defmodule Bones73kWeb.PropertyLive.FormComponent do
defp save_property(socket, :edit, property_params) do
case Properties.update_property(socket.assigns.property, property_params) do
{:ok, _property} ->
flash = {:info, "Property updated successfully"}
send(self(), {:put_flash_message, flash})
socket
|> put_flash(:info, "Property updated successfully")
|> push_event("modal-please-hide", %{})
|> live_noreply()
@ -44,8 +46,10 @@ defmodule Bones73kWeb.PropertyLive.FormComponent do
case Properties.create_property(property_params) do
{:ok, _property} ->
flash = {:info, "Property created successfully"}
send(self(), {:put_flash_message, flash})
socket
|> put_flash(:info, "Property created successfully")
|> push_event("modal-please-hide", %{})
|> live_noreply()

View file

@ -18,13 +18,16 @@ defmodule Bones73kWeb.PropertyLive.Index do
property = property_from_params(params)
if Roles.can?(current_user, property, live_action) do
socket = assign(socket, :properties, list_properties())
{:noreply, apply_action(socket, live_action, params)}
socket
|> assign(:properties, list_properties())
|> assign(:modal_return_to, Routes.property_index_path(socket, :index))
|> apply_action(live_action, params)
|> live_noreply()
else
{:noreply,
socket
|> put_flash(:error, "Unauthorised")
|> redirect(to: "/")}
socket
|> put_flash(:error, "Unauthorised")
|> redirect(to: "/")
|> live_noreply()
end
end
@ -64,6 +67,16 @@ defmodule Bones73kWeb.PropertyLive.Index do
end
end
@impl true
def handle_info({:close_modal, _}, %{assigns: %{modal_return_to: to}} = socket) do
socket |> copy_flash() |> push_patch(to: to) |> live_noreply()
end
@impl true
def handle_info({:put_flash_message, {flash_type, msg}}, socket) do
socket |> put_flash(flash_type, msg) |> live_noreply()
end
defp property_from_params(params)
defp property_from_params(%{"id" => id}),

View file

@ -1,18 +1,17 @@
<div class="d-flex justify-content-between d-flex align-items-end">
<h2>Listing Properties</h2>
<span><%= live_patch "New Property", to: Routes.property_index_path(@socket, :new), class: "btn btn-primary" %></span>
</div>
<%= if @live_action in [:new, :edit] do %>
<%= live_modal @socket, Bones73kWeb.PropertyLive.FormComponent,
id: @property.id || :new,
title: @page_title,
action: @live_action,
property: @property,
current_user: @current_user,
return_to: Routes.property_index_path(@socket, :index) %>
current_user: @current_user %>
<% end %>
<div class="d-flex justify-content-between d-flex align-items-end">
<h2>Listing Properties</h2>
<span><%= live_patch "New Property", to: Routes.property_index_path(@socket, :new), class: "btn btn-primary" %></span>
</div>
<table class="table">
<thead>
<tr>

View file

@ -40,7 +40,7 @@ defmodule Bones73kWeb.UserLive.Registration do
|> Accounts.register_user()
|> case do
{:ok, user} ->
%Bamboo.Email{} =
{:ok, %Bamboo.Email{}} =
Accounts.deliver_user_confirmation_instructions(
user,
&Routes.user_confirmation_url(socket, :confirm, &1)

View file

@ -13,7 +13,7 @@
<%= label f, :email, class: "form-label" %>
<div class="input-group has-validation">
<span class="input-group-text">
<%= icon_div @socket, "bi-at", [class: "icon fs-5"] %>
<%= icon_div @socket, "bi-at", [class: "icon"] %>
</span>
<%= email_input f, :email,
value: input_value(f, :email),
@ -32,14 +32,13 @@
<%= label f, :password, class: "form-label" %>
<div class="input-group has-validation">
<span class="input-group-text">
<%= icon_div @socket, "bi-key", [class: "icon fs-5"] %>
<%= icon_div @socket, "bi-key", [class: "icon"] %>
</span>
<%= password_input f, :password,
value: input_value(f, :password),
class: input_class(f, :password, "form-control"),
maxlength: User.max_password,
required: true,
phx_debounce: "200",
phx_debounce: "250",
aria_describedby: error_id(f, :password)
%>
<%= error_tag f, :password %>
@ -47,7 +46,12 @@
</div>
<div class="mb-3">
<%= submit (@trigger_submit && "Saving..." || "Register"), disabled: @trigger_submit || !@changeset.valid?, class: "btn btn-primary", phx_disable_with: "Saving..." %>
<%= submit (@trigger_submit && "Saving..." || "Register"),
class: "btn btn-primary",
disabled: @trigger_submit || !@changeset.valid?,
aria_disabled: (@trigger_submit || !@changeset.valid?) && "true" || false,
phx_disable_with: "Saving..."
%>
</div>
<% end %>

View file

@ -13,7 +13,7 @@
<%= label f, :password, "New password", class: "form-label" %>
<div class="input-group has-validation">
<span class="input-group-text">
<%= icon_div @socket, "bi-key", [class: "icon fs-5"] %>
<%= icon_div @socket, "bi-key", [class: "icon"] %>
</span>
<%= password_input f, :password,
value: input_value(f, :password),
@ -30,7 +30,7 @@
<%= label f, :password_confirmation, "Confirm new password", class: "form-label" %>
<div class="input-group has-validation">
<span class="input-group-text">
<%= icon_div @socket, "bi-key-fill", [class: "icon fs-5"] %>
<%= icon_div @socket, "bi-key-fill", [class: "icon"] %>
</span>
<%= password_input f, :password_confirmation,
value: input_value(f, :password_confirmation),
@ -43,7 +43,12 @@
</div>
<div class="mb-3">
<%= submit "Reset password", disabled: !@changeset.valid?, class: "btn btn-primary", phx_disable_with: "Saving..." %>
<%= submit "Reset password",
class: "btn btn-primary",
disabled: !@changeset.valid?,
aria_disabled: !@changeset.valid? && "true" || false,
phx_disable_with: "Saving..."
%>
</div>
<% end %>

View file

@ -8,14 +8,14 @@
<%= label f, :email, class: "form-label" %>
<div class="input-group has-validation">
<span class="input-group-text">
<%= icon_div @socket, "bi-at", [class: "icon fs-5"] %>
<%= icon_div @socket, "bi-at", [class: "icon"] %>
</span>
<%= email_input f, :email,
value: input_value(f, :email),
class: input_class(f, :email, "form-control"),
placeholder: "e.g., babka@73k.us",
maxlength: User.max_email,
phx_debounce: "600",
phx_debounce: "500",
aria_describedby: error_id(f, :email)
%>
<%= error_tag f, :email %>
@ -27,12 +27,11 @@
<%= label f, :current_password, class: "form-label" %>
<div class="input-group">
<span class="input-group-text">
<%= icon_div @socket, "bi-lock", [class: "icon fs-5"] %>
<%= icon_div @socket, "bi-lock", [class: "icon"] %>
</span>
<%= password_input f, :current_password,
value: input_value(f, :current_password),
class: "form-control",
required: true,
aria_describedby: error_id(f, :current_password)
%>
<%= error_tag f, :current_password %>
@ -41,8 +40,9 @@
<div class="mb-3">
<%= submit "Change email",
disabled: !@changeset.valid? || input_value(f, :current_password) == "",
class: "btn btn-primary",
disabled: !@changeset.valid? || input_value(f, :current_password) == "",
aria_disabled: (!@changeset.valid? || input_value(f, :current_password) == "") && "true" || false,
phx_disable_with: "Saving..."
%>
</div>

View file

@ -8,13 +8,13 @@
<%= label f, :password, "New password", class: "form-label" %>
<div class="input-group has-validation">
<span class="input-group-text">
<%= icon_div @socket, "bi-key", [class: "icon fs-5"] %>
<%= icon_div @socket, "bi-key", [class: "icon"] %>
</span>
<%= password_input f, :password,
value: input_value(f, :password),
class: input_class(f, :password, "form-control"),
maxlength: User.max_password,
phx_debounce: "600",
phx_debounce: "500",
aria_describedby: error_id(f, :password)
%>
<%= error_tag f, :password %>
@ -25,7 +25,7 @@
<%= label f, :password_confirmation, "Confirm new password", class: "form-label" %>
<div class="input-group has-validation">
<span class="input-group-text">
<%= icon_div @socket, "bi-key-fill", [class: "icon fs-5"] %>
<%= icon_div @socket, "bi-key-fill", [class: "icon"] %>
</span>
<%= password_input f, :password_confirmation,
value: input_value(f, :password_confirmation),
@ -41,12 +41,11 @@
<%= label f, :current_password, class: "form-label" %>
<div class="input-group">
<span class="input-group-text">
<%= icon_div @socket, "bi-lock", [class: "icon fs-5"] %>
<%= icon_div @socket, "bi-lock", [class: "icon"] %>
</span>
<%= password_input f, :current_password,
value: input_value(f, :current_password),
class: "form-control",
required: true,
aria_describedby: error_id(f, :current_password)
%>
<%= error_tag f, :current_password %>
@ -55,8 +54,9 @@
<div class="mb-3">
<%= submit "Change password",
disabled: !@changeset.valid? || input_value(f, :current_password) == "",
class: "btn btn-primary",
disabled: !@changeset.valid? || input_value(f, :current_password) == "",
aria_disabled: (!@changeset.valid? || input_value(f, :current_password) == "") && "true" || false,
phx_disable_with: "Saving..."
%>
</div>

View file

@ -0,0 +1,91 @@
defmodule Bones73kWeb.UserManagement.FormComponent do
use Bones73kWeb, :live_component
alias Bones73k.{Repo, Accounts}
alias Bones73k.Accounts.User
alias Bones73kWeb.Roles
@impl true
def update(assigns, socket) do
socket
|> assign(assigns)
|> init_changeset(assigns)
|> assign(:role_id, 1)
|> live_okreply()
end
defp init_changeset(socket, %{action: :new, user: user}) do
params = %{role: Accounts.registration_role()}
assign(socket, changeset: Accounts.change_user_registration(user, params))
end
defp init_changeset(socket, %{action: :edit, user: user}) do
assign(socket, changeset: Accounts.change_user_update(user))
end
defp validate_changes(%{action: :new, user: user}, user_params) do
Accounts.change_user_registration(user, user_params)
end
defp validate_changes(%{action: :edit, user: user}, user_params) do
Accounts.change_user_update(user, user_params)
end
defp save_user(%{assigns: %{action: :new}} = socket, user_params) do
case Accounts.register_user(user_params) do
{:ok, user} ->
{:ok, %Bamboo.Email{}} =
Accounts.deliver_user_confirmation_instructions(
user,
&Routes.user_confirmation_url(socket, :confirm, &1)
)
flash = {:success, "User created successfully: #{user.email}"}
send(self(), {:put_flash_message, flash})
socket
|> push_event("modal-please-hide", %{})
|> live_noreply()
{:error, cs} ->
socket
|> put_flash(:error, "Some error creating this user...")
|> assign(changeset: cs)
|> live_noreply()
end
end
defp save_user(%{assigns: %{action: :edit, user: user}} = socket, user_params) do
case Accounts.update_user(user, user_params) do
{:ok, user} ->
flash = {:info, "User updated successfully: #{user.email}"}
send(self(), {:put_flash_message, flash})
socket
|> push_event("modal-please-hide", %{})
|> live_noreply()
{:error, cs} ->
{:noreply, assign(socket, :changeset, cs)}
end
end
@impl true
def handle_event("validate", %{"user" => user_params}, socket) do
cs = validate_changes(socket.assigns, user_params)
{:noreply, assign(socket, :changeset, %{cs | action: :validate})}
end
@impl true
def handle_event("save", %{"user" => user_params}, socket) do
save_user(socket, user_params)
end
def role_description(role) when is_atom(role) do
Keyword.get(User.roles(), role)
end
def role_description(role) when is_binary(role) do
Keyword.get(User.roles(), String.to_existing_atom(role))
end
end

View file

@ -0,0 +1,67 @@
<%= form_for @changeset, "#", [
phx_target: @myself,
phx_change: "validate",
phx_submit: "save"
], fn f -> %>
<div class="mb-3" phx-feedback-for="<%= input_id(f, :email)%>">
<%= label f, :email, class: "form-label" %>
<div class="input-group has-validation">
<span class="input-group-text">
<%= icon_div @socket, "bi-at", [class: "icon"] %>
</span>
<%= email_input f, :email,
value: input_value(f, :email),
class: input_class(f, :email, "form-control"),
placeholder: "e.g., babka@73k.us",
maxlength: User.max_email,
autofocus: true,
phx_debounce: "250",
aria_describedby: error_id(f, :email)
%>
<%= error_tag f, :email %>
</div>
</div>
<%= if Roles.can?(@current_user, %User{}, :edit_role) do %>
<%= label f, :role, class: "form-label" %>
<div class="input-group mb-3">
<span class="input-group-text">
<%= icon_div @socket, "bi-shield-shaded", [class: "icon"] %>
</span>
<%= select f, :role, Enum.map(User.roles(), fn {k, v} -> {String.capitalize(Atom.to_string(k)), k} end), class: "form-select" %>
<span class="valid-feedback text-primary" style="display: block;">
<%= role_description(input_value(f, :role)) %>
</span>
</div>
<% else %>
<%= hidden_input f, :role, value: input_value(f, :role) %>
<% end %>
<div class="mb-3" phx-feedback-for="<%= input_id(f, :password) %>">
<%= label f, :password, class: "form-label" %>
<div class="input-group has-validation">
<span class="input-group-text">
<%= icon_div @socket, "bi-key", [class: "icon"] %>
</span>
<%= password_input f, :password,
value: input_value(f, :password),
class: input_class(f, :password, "form-control"),
maxlength: User.max_password,
aria_describedby: error_id(f, :password)
%>
<%= error_tag f, :password %>
</div>
</div>
<%= submit "Save",
class: "btn btn-primary",
disabled: !@changeset.valid?,
aria_disabled: !@changeset.valid? && "true" || false,
phx_disable_with: "Saving..."
%>
<% end %>

View file

@ -0,0 +1,204 @@
defmodule Bones73kWeb.UserManagementLive.Index do
use Bones73kWeb, :live_view
import Ecto.Query
import Bones73kWeb.Pagination
import Bones73k.Util.Dt
alias Bones73k.Repo
alias Bones73k.Accounts
alias Bones73k.Accounts.User
alias Bones73kWeb.Roles
@impl true
def mount(_params, session, socket) do
socket
|> assign_defaults(session)
|> live_okreply()
end
@impl true
def handle_params(params, _url, socket) do
current_user = socket.assigns.current_user
live_action = socket.assigns.live_action
user = user_from_params(params)
if Roles.can?(current_user, user, live_action) do
socket
|> assign(:query, query_map(params))
|> assign_modal_return_to()
|> page_query()
|> apply_action(socket.assigns.live_action, params)
|> live_noreply()
else
socket
|> put_flash(:error, "Unauthorised")
|> redirect(to: "/")
|> live_noreply()
end
end
defp apply_action(socket, :index, _params) do
socket
|> assign(:page_title, "Listing Users")
|> assign(:user, nil)
end
defp apply_action(socket, :new, _params) do
socket
|> assign(:page_title, "New User")
|> assign(:user, %User{})
end
defp apply_action(socket, :edit, %{"id" => id}) do
socket
|> assign(:page_title, "Edit User")
|> assign(:user, Accounts.get_user!(id))
end
def assign_modal_return_to(%{assigns: %{query: query}} = socket) do
to = Routes.user_management_index_path(socket, :index, Enum.into(query, []))
assign(socket, :modal_return_to, to)
end
defp user_from_params(params)
defp user_from_params(%{"id" => id}),
do: Accounts.get_user!(id)
defp user_from_params(_params), do: %User{}
def query_map(params) do
%{
filter: params["filter"] || "",
sort_by: (params["sort_by"] in ~w(email inserted_at role) && params["sort_by"]) || "email",
sort_order: (params["sort_order"] == "desc" && "desc") || "asc",
page_number: String.to_integer(params["page_number"] || "1"),
page_size: String.to_integer(params["page_size"] || "10")
}
end
defp page_query(%{assigns: %{query: query}} = socket) do
result_page =
from(u in User)
|> or_where([u], ilike(u.email, ^"%#{query.filter}%"))
# |> or_where([u], ilike(u.singer_name, ^"%#{query.filter}%"))
|> order_by([u], [
{^String.to_existing_atom(query.sort_order), ^String.to_existing_atom(query.sort_by)}
])
|> Repo.paginate(page: query.page_number, page_size: query.page_size)
socket
|> assign(:page, result_page)
|> assign(:table_loading, false)
end
@impl true
def handle_event("delete", %{"id" => id, "email" => email}, socket) do
id
|> Accounts.get_user()
|> Accounts.delete_user()
|> case do
{:ok, _} ->
socket
|> put_flash(:success, "User deleted successfully: \"#{email}\"")
|> assign(:table_loading, true)
|> page_query()
|> live_noreply()
{:error, _} ->
socket
|> put_flash(
:error,
"Something went wrong attempting to delete user \"#{email}\". Possibly already deleted? Reloading list..."
)
|> assign(:table_loading, true)
|> page_query()
|> live_noreply()
end
end
@impl true
def handle_event("filter-change", params, socket) do
send(self(), {:query_update, Map.put(params, "page_number", "1")})
{:noreply, assign(socket, :table_loading, true)}
end
@impl true
def handle_event("filter-clear", _params, socket) do
send(self(), {:query_update, %{"filter" => "", "page_number" => "1"}})
{:noreply, assign(socket, :table_loading, true)}
end
@impl true
def handle_event(
"sort-change",
%{"sort_by" => column} = params,
%{assigns: %{query: query}} = socket
) do
(column == query.sort_by &&
send(
self(),
{:query_update, %{"sort_order" => (query.sort_order == "asc" && "desc") || "asc"}}
)) ||
send(self(), {:query_update, Map.put(params, "sort_order", "asc")})
{:noreply, assign(socket, :table_loading, true)}
end
@impl true
def handle_event("sort-by-change", %{"sort" => params}, socket) do
send(self(), {:query_update, params})
{:noreply, assign(socket, :table_loading, true)}
end
@impl true
def handle_event("sort-order-change", _params, socket) do
new_sort_order = (socket.assigns.query.sort_order == "asc" && "desc") || "asc"
send(self(), {:query_update, %{"sort_order" => new_sort_order}})
{:noreply, assign(socket, :table_loading, true)}
end
@impl true
def handle_event("page-change", params, socket) do
send(self(), {:query_update, params})
{:noreply, assign(socket, :table_loading, true)}
end
@impl true
def handle_event("page-size-change", params, socket) do
send(self(), {:query_update, Map.put(params, "page_number", "1")})
{:noreply, assign(socket, :table_loading, true)}
end
@impl true
def handle_info({:query_update, params}, %{assigns: %{query: q}} = socket) do
{:noreply,
push_patch(socket,
to: Routes.user_management_index_path(socket, :index, get_new_params(params, q)),
replace: true
)}
end
@impl true
def handle_info({:close_modal, _}, %{assigns: %{modal_return_to: to}} = socket) do
socket |> copy_flash() |> push_patch(to: to) |> live_noreply()
end
@impl true
def handle_info({:put_flash_message, {flash_type, msg}}, socket) do
socket |> put_flash(flash_type, msg) |> live_noreply()
end
defp get_new_params(params, query) do
[
{:filter, Map.get(params, "filter") || query.filter},
{:sort_by, Map.get(params, "sort_by") || query.sort_by},
{:sort_order, Map.get(params, "sort_order") || query.sort_order},
{:page_number, Map.get(params, "page_number") || query.page_number},
{:page_size, Map.get(params, "page_size") || query.page_size}
]
end
def dt_out(ndt), do: format_ndt(ndt, "{YYYY} {Mshort} {0D}, {h12}:{0m} {am}")
end

View file

@ -0,0 +1,195 @@
<%= if @live_action in [:new, :edit] do %>
<%= live_modal @socket, Bones73kWeb.UserManagement.FormComponent,
id: @user.id || :new,
title: @page_title,
action: @live_action,
user: @user,
current_user: @current_user %>
<% end %>
<h2 class="mb-3">
<%= icon_div @socket, "bi-people", [class: "icon baseline"] %>
Listing Users
</h2>
<%# filtering and new item creation %>
<div class="d-flex flex-column flex-sm-row justify-content-between d-flex align-items-start">
<%= live_patch to: Routes.user_management_index_path(@socket, :new, Enum.into(@query, [])),
class: "btn btn-primary mb-2" do %>
<%= icon_div @socket, "bi-person-plus", [class: "icon baseline me-1"] %>
New User
<% end %>
<%= form_for :filter, "#", [phx_change: "filter-change"], fn flt -> %>
<div class="input-group mb-2">
<span class="input-group-text">
<%= icon_div @socket, "bi-filter", [class: "icon"] %>
</span>
<%= text_input flt, :filter, name: "filter", class: "form-control", placeholder: "Filter users...", value: @query.filter %>
</button>
<%= if @query.filter == "" do %>
<button class="btn btn-outline-secondary" type="button" aria-label="Clear filter" aria-disabled="true" disabled>
<% else %>
<button class="btn btn-outline-secondary" type="button" aria-label="Clear filter" phx-click="filter-clear">
<% end %>
<%= icon_div @socket, "bi-backspace", [class: "icon baseline"], [role: "img", aria_hidden: false] %>
</button>
</div>
<% end %>
</div>
<%# main data table %>
<table class="table">
<thead>
<tr class="<%= @table_loading && "loading" || "" %>">
<th scope="col" phx-click="sort-change" phx-value-sort_by="email" class="cursor-pointer">
Email
<%= if @query.sort_by == "email", do: icon_div @socket,
(@query.sort_order == "desc" && "bi-sort-up-alt" || "bi-sort-down-alt"),
[class: "icon baseline ms-1"]
%>
</th>
<th scope="col" phx-click="sort-change" phx-value-sort_by="role" class="cursor-pointer">
Role
<%= if @query.sort_by == "role", do: icon_div @socket,
(@query.sort_order == "desc" && "bi-sort-up-alt" || "bi-sort-down-alt"),
[class: "icon baseline ms-1"]
%>
</th>
<th scope="col" phx-click="sort-change" phx-value-sort_by="inserted_at" class="cursor-pointer">
Created at
<%= if @query.sort_by == "inserted_at", do: icon_div @socket,
(@query.sort_order == "desc" && "bi-sort-up-alt" || "bi-sort-down-alt"),
[class: "icon baseline ms-1"]
%>
</th>
<th scope="col">Confirmed?</th>
<th></th>
</tr>
</thead>
<tbody id="users">
<%= for user <- @page.entries do %>
<tr id="user-<%= user.id %>">
<td class="align-middle"><%= user.email %></td>
<td class="align-middle"><%= user.role |> Atom.to_string() |> String.capitalize() %></td>
<td class="align-middle" style="white-space: nowrap;"><%= dt_out(user.inserted_at) %></td>
<td class="align-middle">
<%= if user.confirmed_at do %>
<span class="visually-hidden">Yes</span>
<%= icon_div @socket, "bi-check", [class: "icon baseline fs-4 text-success"], [role: "img", aria_hidden: false] %>
<% else %>
<span class="visually-hidden">No</span>
<%= icon_div @socket, "bi-x", [class: "icon baseline fs-4 text-warning"], [role: "img", aria_hidden: false] %>
<% end %>
</td>
<td class="align-middle text-end">
<%= live_patch to: Routes.user_management_index_path(@socket, :edit, user.id, Enum.into(@query, [])), class: "btn btn-outline-primary btn-sm" do %>
<%= icon_div @socket, "bi-pencil", [class: "icon baseline", style: "margin-right:0.125rem;"] %>
Edit
<% end %>
<%= if Roles.can?(@current_user, user, :delete) do %>
<%= link to: "#",
phx_click: "delete",
phx_value_id: user.id,
phx_value_email: user.email,
data: [confirm: "Are you sure you want to delete this user? \"#{user.email}\""],
class: "btn btn-outline-danger btn-sm" do %>
<%= icon_div @socket, "bi-trash", [class: "icon baseline", style: "margin-right:0.125rem;"] %>
Delete
<% end %>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
<%# pagination interface %>
<div class="d-flex justify-content-between d-flex align-items-start">
<%# items per page selector %>
<div class="d-flex align-items-center">
<%= form_for :page_size, "#", [phx_change: "page-size-change"], fn pgsz -> %>
<%= select pgsz, :page_size,
[10, 15, 20, 30, 50, 100] |> Enum.map(fn n -> {"#{n} per page", n} end),
value: @query.page_size,
id: "table_page_size_page_size",
name: "page_size",
class: "form-select"
%>
<% end %>
<span class="ms-2"><%= @page.total_entries %> found</span>
</div>
<%# main pagination %>
<nav aria-label="User list page navigation">
<ul class="pagination mb-0">
<%# previous page button %>
<% icon = icon_div @socket, "bi-chevron-left", [class: "icon baseline"] %>
<%= if @page.page_number == 1 do %>
<li class="page-item disabled">
<span class="page-link" aria-hidden="true"><%= icon %></span>
<span class="visually-hidden">Previous</span>
<% else %>
<li class="page-item">
<a class="page-link" href="#" aria-label="Previous" phx-value-page_number="<%= @page.page_number - 1 %>" phx-click="page-change"><%= icon %></a>
<% end %>
</li>
<%# page buttons %>
<%= for page_num <- generate_page_list(@page.page_number, @page.total_pages) do %>
<%= cond do %>
<%= page_num < 1 -> %>
<li class="page-item disabled">
<span class="page-link" aria-hidden="true">&hellip;</span>
<span class="visually-hidden" role="img" aria-label="ellipses">&hellip;</span>
</li>
<%= page_num == @page.page_number -> %>
<li class="page-item active" aria-current="page">
<span class="page-link"><%= page_num %></a>
</li>
<%= true -> %>
<li class="page-item">
<a class="page-link" href="#" phx-value-page_number="<%= page_num %>" phx-click="page-change"><%= page_num %></a>
</li>
<% end %>
<% end %>
<%# next page button %>
<% icon = icon_div @socket, "bi-chevron-right", [class: "icon baseline"] %>
<%= if @page.page_number == @page.total_pages do %>
<li class="page-item disabled">
<span class="page-link" aria-hidden="true"><%= icon %></span>
<span class="visually-hidden">Next</span>
<% else %>
<li class="page-item">
<a class="page-link" href="#" aria-label="Next" phx-value-page_number="<%= @page.page_number + 1 %>" phx-click="page-change"><%= icon %></a>
<% end %>
</li>
</ul>
</nav>
</div>

View file

@ -7,15 +7,26 @@ defmodule Bones73kWeb.Roles do
alias Bones73k.Properties.Property
@type entity :: struct()
@type action :: :new | :index | :edit | :show | :delete
@type action :: :new | :index | :edit | :show | :delete | :edit_role
@spec can?(%User{}, entity(), action()) :: boolean()
def can?(user, entity, action)
# Properties / Property
def can?(%User{role: :admin}, %Property{}, _any), do: true
def can?(%User{}, %Property{}, :index), do: true
def can?(%User{}, %Property{}, :new), do: true
def can?(%User{}, %Property{}, :show), do: true
def can?(%User{id: id}, %Property{user_id: id}, :edit), do: true
def can?(%User{id: id}, %Property{user_id: id}, :delete), do: true
# Accounts / User
def can?(%User{role: :admin}, %User{}, _any), do: true
def can?(%User{role: :manager}, %User{}, :index), do: true
def can?(%User{role: :manager}, %User{}, :new), do: true
def can?(%User{role: :manager}, %User{}, :edit), do: true
def can?(%User{role: :manager}, %User{}, :show), do: true
# Final response
def can?(_, _, _), do: false
end

View file

@ -105,4 +105,14 @@ defmodule Bones73kWeb.Router do
live("/admin_dashboard", AdminDashboardLive, :index)
end
# Users Management
scope "/users", Bones73kWeb do
pipe_through [:browser, :require_authenticated_user, :manager, :require_email_confirmed]
live("/", UserManagementLive.Index, :index)
live("/new", UserManagementLive.Index, :new)
live("/edit/:id", UserManagementLive.Index, :edit)
# resources "/", UserManagementController, only: [:new, :create, :edit, :update]
end
end

View file

@ -11,6 +11,15 @@
<li><%= content_tag :span, @current_user.email, class: "dropdown-item-text" %></li>
<li><hr class="dropdown-divider"></li>
<%= if Roles.can?(@current_user, %User{}, :index) do %>
<li>
<%= link nav_link_opts(@conn, to: Routes.user_management_index_path(@conn, :index), class: "dropdown-item") do %>
<%= icon_div @conn, "bi-people", [class: "icon baseline me-1"] %>
Users
<% end %>
</li>
<li><hr class="dropdown-divider"></li>
<% end %>
<li>
<%= link nav_link_opts(@conn, to: Routes.user_settings_path(@conn, :edit), class: "dropdown-item") do %>
<%= icon_div @conn, "bi-sliders", [class: "icon baseline me-1"] %>

View file

@ -12,7 +12,7 @@
<%= label f, :email, class: "form-label" %>
<div class="input-group has-validation mb-3">
<span class="input-group-text">
<%= icon_div @conn, "bi-at", [class: "icon fs-5"] %>
<%= icon_div @conn, "bi-at", [class: "icon"] %>
</span>
<%= email_input f, :email,
placeholder: "e.g., babka@73k.us",

View file

@ -18,7 +18,7 @@
<%= label f, :email, class: "form-label" %>
<div class="input-group has-validation mb-3">
<span class="input-group-text">
<%= icon_div @conn, "bi-at", [class: "icon fs-5"] %>
<%= icon_div @conn, "bi-at", [class: "icon"] %>
</span>
<%= email_input f, :email,
class: "form-control",
@ -32,7 +32,7 @@
<%= label f, :password, class: "form-label" %>
<div class="input-group has-validation mb-3">
<span class="input-group-text">
<%= icon_div @conn, "bi-lock", [class: "icon fs-5"] %>
<%= icon_div @conn, "bi-lock", [class: "icon"] %>
</span>
<%= password_input f, :password,
class: "form-control",

View file

@ -1,6 +1,9 @@
defmodule Bones73kWeb.LayoutView do
use Bones73kWeb, :view
alias Bones73k.Accounts.User
alias Bones73kWeb.Roles
def nav_link_opts(conn, opts) do
case Keyword.get(opts, :to) == Phoenix.Controller.current_path(conn) do
false -> opts

View file

@ -0,0 +1,29 @@
defmodule Bones73kWeb.Pagination do
def generate_page_list(_, total_pages) when total_pages < 5,
do: 1..total_pages |> Enum.to_list()
def generate_page_list(current, total_pages),
do: first_half(1, current) ++ [current] ++ second_half(current, total_pages)
defp first_half(first, current) do
prev = current - 1
cond do
first == current -> []
prev <= first -> [first]
prev - first > 2 -> [first, -1, prev]
true -> first..prev |> Enum.to_list()
end
end
defp second_half(current, last) do
next = current + 1
cond do
last == current -> []
next >= last -> [last]
last - next > 2 -> [next, -1, last]
true -> next..last |> Enum.to_list()
end
end
end