refactored for new project name
This commit is contained in:
parent
0039146cd4
commit
82ab1d1ea5
113 changed files with 417 additions and 412 deletions
lib/shift73k_web
channels
controllers
other_controller.exuser_auth.exuser_confirmation_controller.exuser_registration_controller.exuser_reset_password_controller.exuser_session_controller.exuser_settings_controller.ex
endpoint.exgettext.exlive
admin_dashboard_live.exlive_helpers.exmodal_component.expage_live.expage_live.html.leex
property_live
user
registration.exregistration.html.leexreset_password.exreset_password.html.leexsettings.exsettings.html.leex
user_dashboard_live.exsettings
user_management
plugs
roles.exrouter.extelemetry.extemplates
layout
other
user_confirmation
user_reset_password
user_session
views
35
lib/shift73k_web/channels/user_socket.ex
Normal file
35
lib/shift73k_web/channels/user_socket.ex
Normal file
|
@ -0,0 +1,35 @@
|
|||
defmodule Shift73kWeb.UserSocket do
|
||||
use Phoenix.Socket
|
||||
|
||||
## Channels
|
||||
# channel "room:*", Shift73kWeb.RoomChannel
|
||||
|
||||
# Socket params are passed from the client and can
|
||||
# be used to verify and authenticate a user. After
|
||||
# verification, you can put default assigns into
|
||||
# the socket that will be set for all channels, ie
|
||||
#
|
||||
# {:ok, assign(socket, :user_id, verified_user_id)}
|
||||
#
|
||||
# To deny connection, return `:error`.
|
||||
#
|
||||
# See `Phoenix.Token` documentation for examples in
|
||||
# performing token verification on connect.
|
||||
@impl true
|
||||
def connect(_params, socket, _connect_info) do
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
# Socket id's are topics that allow you to identify all sockets for a given user:
|
||||
#
|
||||
# def id(socket), do: "user_socket:#{socket.assigns.user_id}"
|
||||
#
|
||||
# Would allow you to broadcast a "disconnect" event and terminate
|
||||
# all active sockets and channels for a given user:
|
||||
#
|
||||
# Shift73kWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{})
|
||||
#
|
||||
# Returning `nil` makes this socket anonymous.
|
||||
@impl true
|
||||
def id(_socket), do: nil
|
||||
end
|
16
lib/shift73k_web/controllers/other_controller.ex
Normal file
16
lib/shift73k_web/controllers/other_controller.ex
Normal file
|
@ -0,0 +1,16 @@
|
|||
defmodule Shift73kWeb.OtherController do
|
||||
use Shift73kWeb, :controller
|
||||
|
||||
def index(conn, _params) do
|
||||
conn
|
||||
|> put_flash(:success, "Log in was a success. Good for you.")
|
||||
|> put_flash(:error, "Lorem ipsum dolor sit amet consectetur adipisicing elit.")
|
||||
|> put_flash(
|
||||
:info,
|
||||
"Lorem ipsum dolor sit amet consectetur adipisicing elit. Voluptatibus dolore sunt quia aperiam sint id reprehenderit? Dolore incidunt alias inventore accusantium nulla optio, ducimus eius aliquam hic, pariatur voluptate distinctio."
|
||||
)
|
||||
|> put_flash(:warning, "Oh no, there's nothing to worry about!")
|
||||
|> put_flash(:primary, "Something in the brand color.")
|
||||
|> render("index.html")
|
||||
end
|
||||
end
|
171
lib/shift73k_web/controllers/user_auth.ex
Normal file
171
lib/shift73k_web/controllers/user_auth.ex
Normal file
|
@ -0,0 +1,171 @@
|
|||
defmodule Shift73kWeb.UserAuth do
|
||||
import Plug.Conn
|
||||
import Phoenix.Controller
|
||||
|
||||
alias Shift73k.Accounts
|
||||
alias Shift73kWeb.Router.Helpers, as: Routes
|
||||
|
||||
@pubsub_topic "user_updates"
|
||||
|
||||
# Make the remember me cookie valid for 60 days.
|
||||
# If you want bump or reduce this value, also change
|
||||
# the token expiry itself in UserToken.
|
||||
@max_age 60 * 60 * 24 * 60
|
||||
@remember_me_cookie "user_remember_me"
|
||||
@remember_me_options [sign: true, max_age: @max_age, same_site: "Lax"]
|
||||
|
||||
@doc """
|
||||
Logs the user in.
|
||||
|
||||
It renews the session ID and clears the whole session
|
||||
to avoid fixation attacks. See the renew_session
|
||||
function to customize this behaviour.
|
||||
|
||||
It also sets a `:live_socket_id` key in the session,
|
||||
so LiveView sessions are identified and automatically
|
||||
disconnected on log out. The line can be safely removed
|
||||
if you are not using LiveView.
|
||||
"""
|
||||
def log_in_user(conn, user, params \\ %{}) do
|
||||
token = Accounts.generate_user_session_token(user)
|
||||
|
||||
conn
|
||||
|> renew_session()
|
||||
|> put_session(:user_token, token)
|
||||
|> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}")
|
||||
|> maybe_write_remember_me_cookie(token, params)
|
||||
|> redirect(to: get_session(conn, :user_return_to) || signed_in_path(conn))
|
||||
end
|
||||
|
||||
defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}) do
|
||||
put_resp_cookie(conn, @remember_me_cookie, token, @remember_me_options)
|
||||
end
|
||||
|
||||
defp maybe_write_remember_me_cookie(conn, _token, _params) do
|
||||
conn
|
||||
end
|
||||
|
||||
# This function renews the session ID and erases the whole
|
||||
# session to avoid fixation attacks. If there is any data
|
||||
# in the session you may want to preserve after log in/log out,
|
||||
# you must explicitly fetch the session data before clearing
|
||||
# and then immediately set it after clearing, for example:
|
||||
#
|
||||
# defp renew_session(conn) do
|
||||
# preferred_locale = get_session(conn, :preferred_locale)
|
||||
#
|
||||
# conn
|
||||
# |> configure_session(renew: true)
|
||||
# |> clear_session()
|
||||
# |> put_session(:preferred_locale, preferred_locale)
|
||||
# end
|
||||
#
|
||||
defp renew_session(conn) do
|
||||
conn
|
||||
|> configure_session(renew: true)
|
||||
|> clear_session()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Logs the user out.
|
||||
|
||||
It clears all session data for safety. See renew_session.
|
||||
"""
|
||||
def log_out_user(conn) do
|
||||
user_token = get_session(conn, :user_token)
|
||||
user_token && Accounts.delete_session_token(user_token)
|
||||
|
||||
if live_socket_id = get_session(conn, :live_socket_id) do
|
||||
Shift73kWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
|
||||
end
|
||||
|
||||
conn
|
||||
|> renew_session()
|
||||
|> delete_resp_cookie(@remember_me_cookie)
|
||||
|> redirect(to: "/")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Authenticates the user by looking into the session
|
||||
and remember me token.
|
||||
"""
|
||||
def fetch_current_user(conn, _opts) do
|
||||
{user_token, conn} = ensure_user_token(conn)
|
||||
user = user_token && Accounts.get_user_by_session_token(user_token)
|
||||
assign(conn, :current_user, user)
|
||||
end
|
||||
|
||||
defp ensure_user_token(conn) do
|
||||
if user_token = get_session(conn, :user_token) do
|
||||
{user_token, conn}
|
||||
else
|
||||
conn = fetch_cookies(conn, signed: [@remember_me_cookie])
|
||||
|
||||
if user_token = conn.cookies[@remember_me_cookie] do
|
||||
{user_token, put_session(conn, :user_token, user_token)}
|
||||
else
|
||||
{nil, conn}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Used for routes that require the user to not be authenticated.
|
||||
"""
|
||||
def redirect_if_user_is_authenticated(conn, _opts) do
|
||||
if conn.assigns[:current_user] do
|
||||
conn
|
||||
|> redirect(to: signed_in_path(conn))
|
||||
|> halt()
|
||||
else
|
||||
conn
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Used for routes that require the user to be authenticated.
|
||||
|
||||
If you want to enforce the user email is confirmed before
|
||||
they use the application at all, here would be a good place.
|
||||
"""
|
||||
def require_authenticated_user(conn, _opts) do
|
||||
if conn.assigns[:current_user] do
|
||||
conn
|
||||
else
|
||||
conn
|
||||
|> put_flash(:error, "You must log in to access this page.")
|
||||
|> maybe_store_return_to()
|
||||
|> redirect(to: Routes.user_session_path(conn, :new))
|
||||
|> halt()
|
||||
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
|
||||
"""
|
||||
def pubsub_topic, do: @pubsub_topic
|
||||
|
||||
defp maybe_store_return_to(%{method: "GET"} = conn) do
|
||||
%{request_path: request_path, query_string: query_string} = conn
|
||||
return_to = if query_string == "", do: request_path, else: request_path <> "?" <> query_string
|
||||
put_session(conn, :user_return_to, return_to)
|
||||
end
|
||||
|
||||
defp maybe_store_return_to(conn), do: conn
|
||||
|
||||
defp signed_in_path(_conn), do: "/"
|
||||
end
|
43
lib/shift73k_web/controllers/user_confirmation_controller.ex
Normal file
43
lib/shift73k_web/controllers/user_confirmation_controller.ex
Normal file
|
@ -0,0 +1,43 @@
|
|||
defmodule Shift73kWeb.UserConfirmationController do
|
||||
use Shift73kWeb, :controller
|
||||
|
||||
alias Shift73k.Accounts
|
||||
|
||||
def new(conn, _params) do
|
||||
render(conn, "new.html")
|
||||
end
|
||||
|
||||
def create(conn, %{"user" => %{"email" => email}}) do
|
||||
if user = Accounts.get_user_by_email(email) do
|
||||
Accounts.deliver_user_confirmation_instructions(
|
||||
user,
|
||||
&Routes.user_confirmation_url(conn, :confirm, &1)
|
||||
)
|
||||
end
|
||||
|
||||
# Regardless of the outcome, show an impartial success/error message.
|
||||
conn
|
||||
|> put_flash(
|
||||
:info,
|
||||
"If your email is in our system and it has not been confirmed yet, " <>
|
||||
"you will receive an email with instructions shortly."
|
||||
)
|
||||
|> redirect(to: "/")
|
||||
end
|
||||
|
||||
# Do not log in the user after confirmation to avoid a
|
||||
# leaked token giving the user access to the account.
|
||||
def confirm(conn, %{"token" => token}) do
|
||||
case Accounts.confirm_user(token) do
|
||||
{:ok, _} ->
|
||||
conn
|
||||
|> put_flash(:info, "Account confirmed successfully.")
|
||||
|> redirect(to: "/")
|
||||
|
||||
:error ->
|
||||
conn
|
||||
|> put_flash(:error, "Confirmation link is invalid or it has expired.")
|
||||
|> redirect(to: "/")
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,8 @@
|
|||
defmodule Shift73kWeb.UserRegistrationController do
|
||||
use Shift73kWeb, :controller
|
||||
import Phoenix.LiveView.Controller
|
||||
|
||||
def new(conn, _params) do
|
||||
live_render(conn, Shift73kWeb.UserLive.Registration)
|
||||
end
|
||||
end
|
|
@ -0,0 +1,46 @@
|
|||
defmodule Shift73kWeb.UserResetPasswordController do
|
||||
use Shift73kWeb, :controller
|
||||
import Phoenix.LiveView.Controller
|
||||
|
||||
alias Shift73k.Accounts
|
||||
|
||||
plug(:get_user_by_reset_password_token when action in [:edit])
|
||||
|
||||
def new(conn, _params) do
|
||||
render(conn, "new.html")
|
||||
end
|
||||
|
||||
def create(conn, %{"user" => %{"email" => email}}) do
|
||||
if user = Accounts.get_user_by_email(email) do
|
||||
Accounts.deliver_user_reset_password_instructions(
|
||||
user,
|
||||
&Routes.user_reset_password_url(conn, :edit, &1)
|
||||
)
|
||||
end
|
||||
|
||||
# Regardless of the outcome, show an impartial success/error message.
|
||||
conn
|
||||
|> put_flash(
|
||||
:info,
|
||||
"If your email is in our system, you'll receive instructions to reset your password shortly."
|
||||
)
|
||||
|> redirect(to: "/")
|
||||
end
|
||||
|
||||
def edit(conn, _params) do
|
||||
live_render(conn, Shift73kWeb.UserLive.ResetPassword)
|
||||
end
|
||||
|
||||
defp get_user_by_reset_password_token(conn, _opts) do
|
||||
%{"token" => token} = conn.params
|
||||
|
||||
if user = Accounts.get_user_by_reset_password_token(token) do
|
||||
put_session(conn, "user_id", user.id)
|
||||
else
|
||||
conn
|
||||
|> put_flash(:error, "Reset password link is invalid or it has expired.")
|
||||
|> redirect(to: "/")
|
||||
|> halt()
|
||||
end
|
||||
end
|
||||
end
|
56
lib/shift73k_web/controllers/user_session_controller.ex
Normal file
56
lib/shift73k_web/controllers/user_session_controller.ex
Normal file
|
@ -0,0 +1,56 @@
|
|||
defmodule Shift73kWeb.UserSessionController do
|
||||
use Shift73kWeb, :controller
|
||||
|
||||
alias Phoenix.HTML
|
||||
alias Shift73k.Accounts
|
||||
alias Shift73k.Accounts.User
|
||||
alias Shift73kWeb.UserAuth
|
||||
|
||||
def new(conn, _params) do
|
||||
render(conn, "new.html", error_message: nil)
|
||||
end
|
||||
|
||||
def create(conn, %{"user" => %{"email" => email, "password" => password} = user_params}) do
|
||||
if user = Accounts.get_user_by_email_and_password(email, password) do
|
||||
conn
|
||||
|> put_flash(
|
||||
:info,
|
||||
HTML.raw("Welcome back, #{user.email} — you were logged in successfuly.")
|
||||
)
|
||||
|> UserAuth.log_in_user(user, user_params)
|
||||
else
|
||||
render(conn, "new.html", error_message: "Invalid email or password")
|
||||
end
|
||||
end
|
||||
|
||||
def create(conn, %{"user" => %{"params_token" => token} = user_params}) do
|
||||
with {:ok, params} <- Phoenix.Token.decrypt(Shift73kWeb.Endpoint, "login_params", token),
|
||||
%User{} = user <- Accounts.get_user(params.user_id) do
|
||||
conn
|
||||
|> collect_messages(params.messages)
|
||||
|> put_session(:user_return_to, params.user_return_to)
|
||||
|> UserAuth.log_in_user(user, Map.put_new(user_params, "remember_me", "false"))
|
||||
else
|
||||
_ -> render(conn, "new.html", error_message: "Invalid email or password")
|
||||
end
|
||||
end
|
||||
|
||||
defp collect_messages(conn, messages) do
|
||||
Enum.reduce(messages, conn, fn {type, msg}, acc -> put_flash(acc, type, msg) end)
|
||||
end
|
||||
|
||||
def delete(conn, _params) do
|
||||
conn
|
||||
|> put_flash(:info, "Logged out successfully.")
|
||||
|> UserAuth.log_out_user()
|
||||
end
|
||||
|
||||
def force_logout(conn, _params) do
|
||||
conn
|
||||
|> put_flash(
|
||||
:info,
|
||||
"You were logged out. Please login again to continue using our application."
|
||||
)
|
||||
|> UserAuth.log_out_user()
|
||||
end
|
||||
end
|
19
lib/shift73k_web/controllers/user_settings_controller.ex
Normal file
19
lib/shift73k_web/controllers/user_settings_controller.ex
Normal file
|
@ -0,0 +1,19 @@
|
|||
defmodule Shift73kWeb.UserSettingsController do
|
||||
use Shift73kWeb, :controller
|
||||
|
||||
alias Shift73k.Accounts
|
||||
|
||||
def confirm_email(conn, %{"token" => token}) do
|
||||
case Accounts.update_user_email(conn.assigns.current_user, token) do
|
||||
:ok ->
|
||||
conn
|
||||
|> put_flash(:info, "Email changed successfully.")
|
||||
|> redirect(to: Routes.user_settings_path(conn, :edit))
|
||||
|
||||
:error ->
|
||||
conn
|
||||
|> put_flash(:error, "Email change link is invalid or it has expired.")
|
||||
|> redirect(to: Routes.user_settings_path(conn, :edit))
|
||||
end
|
||||
end
|
||||
end
|
58
lib/shift73k_web/endpoint.ex
Normal file
58
lib/shift73k_web/endpoint.ex
Normal file
|
@ -0,0 +1,58 @@
|
|||
defmodule Shift73kWeb.Endpoint do
|
||||
use Phoenix.Endpoint, otp_app: :shift73k
|
||||
|
||||
# The session will be stored in the cookie and signed,
|
||||
# this means its contents can be read but not tampered with.
|
||||
# Set :encryption_salt if you would also like to encrypt it.
|
||||
@session_options [
|
||||
store: :cookie,
|
||||
key: "_shift73k_key",
|
||||
signing_salt: "9CKxo0VJ"
|
||||
]
|
||||
|
||||
socket("/socket", Shift73kWeb.UserSocket,
|
||||
websocket: true,
|
||||
longpoll: false
|
||||
)
|
||||
|
||||
socket("/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]])
|
||||
|
||||
# Serve at "/" the static files from "priv/static" directory.
|
||||
#
|
||||
# You should set gzip to true if you are running phx.digest
|
||||
# when deploying your static files in production.
|
||||
plug(Plug.Static,
|
||||
at: "/",
|
||||
from: :shift73k,
|
||||
gzip: false,
|
||||
only: ~w(css fonts images js favicon.ico robots.txt)
|
||||
)
|
||||
|
||||
# Code reloading can be explicitly enabled under the
|
||||
# :code_reloader configuration of your endpoint.
|
||||
if code_reloading? do
|
||||
socket("/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket)
|
||||
plug(Phoenix.LiveReloader)
|
||||
plug(Phoenix.CodeReloader)
|
||||
plug(Phoenix.Ecto.CheckRepoStatus, otp_app: :shift73k)
|
||||
end
|
||||
|
||||
plug(Phoenix.LiveDashboard.RequestLogger,
|
||||
param_key: "request_logger",
|
||||
cookie_key: "request_logger"
|
||||
)
|
||||
|
||||
plug(Plug.RequestId)
|
||||
plug(Plug.Telemetry, event_prefix: [:phoenix, :endpoint])
|
||||
|
||||
plug(Plug.Parsers,
|
||||
parsers: [:urlencoded, :multipart, :json],
|
||||
pass: ["*/*"],
|
||||
json_decoder: Phoenix.json_library()
|
||||
)
|
||||
|
||||
plug(Plug.MethodOverride)
|
||||
plug(Plug.Head)
|
||||
plug(Plug.Session, @session_options)
|
||||
plug(Shift73kWeb.Router)
|
||||
end
|
24
lib/shift73k_web/gettext.ex
Normal file
24
lib/shift73k_web/gettext.ex
Normal file
|
@ -0,0 +1,24 @@
|
|||
defmodule Shift73kWeb.Gettext do
|
||||
@moduledoc """
|
||||
A module providing Internationalization with a gettext-based API.
|
||||
|
||||
By using [Gettext](https://hexdocs.pm/gettext),
|
||||
your module gains a set of macros for translations, for example:
|
||||
|
||||
import Shift73kWeb.Gettext
|
||||
|
||||
# Simple translation
|
||||
gettext("Here is the string to translate")
|
||||
|
||||
# Plural translation
|
||||
ngettext("Here is the string to translate",
|
||||
"Here are the strings to translate",
|
||||
3)
|
||||
|
||||
# Domain-based translation
|
||||
dgettext("errors", "Here is the error message to translate")
|
||||
|
||||
See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
|
||||
"""
|
||||
use Gettext, otp_app: :shift73k
|
||||
end
|
18
lib/shift73k_web/live/admin_dashboard_live.ex
Normal file
18
lib/shift73k_web/live/admin_dashboard_live.ex
Normal file
|
@ -0,0 +1,18 @@
|
|||
defmodule Shift73kWeb.AdminDashboardLive do
|
||||
use Shift73kWeb, :live_view
|
||||
|
||||
@impl true
|
||||
def mount(_params, session, socket) do
|
||||
socket = assign_defaults(socket, session)
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~L"""
|
||||
<section class="phx-hero">
|
||||
<h1>Welcome to the admin dashboard!</h1>
|
||||
</section>
|
||||
"""
|
||||
end
|
||||
end
|
69
lib/shift73k_web/live/live_helpers.ex
Normal file
69
lib/shift73k_web/live/live_helpers.ex
Normal file
|
@ -0,0 +1,69 @@
|
|||
defmodule Shift73kWeb.LiveHelpers do
|
||||
import Phoenix.LiveView
|
||||
import Phoenix.LiveView.Helpers
|
||||
|
||||
alias Shift73k.Accounts
|
||||
alias Shift73k.Accounts.User
|
||||
alias Shift73kWeb.UserAuth
|
||||
|
||||
@doc """
|
||||
Performs the {:noreply, socket} for a given socket.
|
||||
This helps make the noreply pipeable
|
||||
"""
|
||||
def live_noreply(socket), do: {:noreply, socket}
|
||||
|
||||
@doc """
|
||||
Performs the {:ok, socket} for a given socket.
|
||||
This helps make the ok reply pipeable
|
||||
"""
|
||||
def live_okreply(socket), do: {:ok, socket}
|
||||
|
||||
@doc """
|
||||
Renders a component inside the `Shift73kWeb.ModalComponent` component.
|
||||
|
||||
The rendered modal receives a `:return_to` option to properly update
|
||||
the URL when the modal is closed.
|
||||
|
||||
## Examples
|
||||
|
||||
<%= live_modal @socket, Shift73kWeb.PropertyLive.FormComponent,
|
||||
id: @property.id || :new,
|
||||
action: @live_action,
|
||||
property: @property,
|
||||
return_to: Routes.property_index_path(@socket, :index) %>
|
||||
"""
|
||||
def live_modal(socket, component, opts) do
|
||||
modal_opts = [id: :modal, component: component, opts: opts]
|
||||
# dirty little workaround for elixir complaining about socket being unused
|
||||
_socket = socket
|
||||
live_component(socket, Shift73kWeb.ModalComponent, modal_opts)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Loads default assigns for liveviews
|
||||
"""
|
||||
def assign_defaults(socket, session) do
|
||||
Shift73kWeb.Endpoint.subscribe(UserAuth.pubsub_topic())
|
||||
assign_current_user(socket, session)
|
||||
end
|
||||
|
||||
# For liveviews, ensures current_user is in socket assigns.
|
||||
def assign_current_user(socket, session) do
|
||||
with user_token when not is_nil(user_token) <- session["user_token"],
|
||||
%User{} = user <- Accounts.get_user_by_session_token(user_token) do
|
||||
assign(socket, :current_user, user)
|
||||
else
|
||||
_ -> 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
|
40
lib/shift73k_web/live/modal_component.ex
Normal file
40
lib/shift73k_web/live/modal_component.ex
Normal file
|
@ -0,0 +1,40 @@
|
|||
defmodule Shift73kWeb.ModalComponent do
|
||||
use Shift73kWeb, :live_component
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~L"""
|
||||
<div id="<%= @id %>" class="modal fade"
|
||||
phx-hook="BsModal"
|
||||
phx-window-keydown="hide"
|
||||
phx-key="escape"
|
||||
phx-target="#<%= @id %>"
|
||||
phx-page-loading>
|
||||
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><%= Keyword.get(@opts, :title, "Modal title") %></h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
|
||||
<%= live_component @socket, @component, @opts %>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("close", _, socket) do
|
||||
send(self(), {:close_modal, true})
|
||||
live_noreply(socket)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("hide", _, socket) do
|
||||
{:noreply, push_event(socket, "modal-please-hide", %{})}
|
||||
end
|
||||
end
|
40
lib/shift73k_web/live/page_live.ex
Normal file
40
lib/shift73k_web/live/page_live.ex
Normal file
|
@ -0,0 +1,40 @@
|
|||
defmodule Shift73kWeb.PageLive do
|
||||
use Shift73kWeb, :live_view
|
||||
|
||||
@impl true
|
||||
def mount(_params, session, socket) do
|
||||
socket = assign_defaults(socket, session)
|
||||
{:ok, assign(socket, query: "", results: %{})}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("suggest", %{"q" => query}, socket) do
|
||||
{:noreply, assign(socket, results: search(query), query: query)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("search", %{"q" => query}, socket) do
|
||||
case search(query) do
|
||||
%{^query => vsn} ->
|
||||
{:noreply, redirect(socket, external: "https://hexdocs.pm/#{query}/#{vsn}")}
|
||||
|
||||
_ ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, "No dependencies found matching \"#{query}\"")
|
||||
|> assign(results: %{}, query: query)}
|
||||
end
|
||||
end
|
||||
|
||||
defp search(query) do
|
||||
if not Shift73kWeb.Endpoint.config(:code_reloader) do
|
||||
raise "action disabled when not in development"
|
||||
end
|
||||
|
||||
for {app, desc, vsn} <- Application.started_applications(),
|
||||
app = to_string(app),
|
||||
String.starts_with?(app, query) and not List.starts_with?(desc, ~c"ERTS"),
|
||||
into: %{},
|
||||
do: {app, vsn}
|
||||
end
|
||||
end
|
49
lib/shift73k_web/live/page_live.html.leex
Normal file
49
lib/shift73k_web/live/page_live.html.leex
Normal file
|
@ -0,0 +1,49 @@
|
|||
<section class="phx-hero">
|
||||
<h1><%= gettext "Welcome to %{name}!", name: "Phoenix" %></h1>
|
||||
<p>Peace of mind from prototype to production</p>
|
||||
|
||||
<form phx-change="suggest" phx-submit="search">
|
||||
<input type="text" name="q" value="<%= @query %>" placeholder="Live dependency search" list="results" autocomplete="off"/>
|
||||
<datalist id="results">
|
||||
<%= for {app, _vsn} <- @results do %>
|
||||
<option value="<%= app %>"><%= app %></option>
|
||||
<% end %>
|
||||
</datalist>
|
||||
<button type="submit" phx-disable-with="Searching...">Go to Hexdocs</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="row align-items-start">
|
||||
<article class="col">
|
||||
<h2>Resources</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://hexdocs.pm/phoenix/overview.html">Guides & Docs</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://github.com/phoenixframework/phoenix">Source</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://github.com/phoenixframework/phoenix/blob/v1.5/CHANGELOG.md">v1.5 Changelog</a>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
<article class="col">
|
||||
<h2>Help</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://elixirforum.com/c/phoenix-forum">Forum</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://webchat.freenode.net/?channels=elixir-lang">#elixir-lang on Freenode IRC</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://twitter.com/elixirphoenix">Twitter @elixirphoenix</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://elixir-slackin.herokuapp.com/">Elixir on Slack</a>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
</section>
|
66
lib/shift73k_web/live/property_live/form_component.ex
Normal file
66
lib/shift73k_web/live/property_live/form_component.ex
Normal file
|
@ -0,0 +1,66 @@
|
|||
defmodule Shift73kWeb.PropertyLive.FormComponent do
|
||||
use Shift73kWeb, :live_component
|
||||
|
||||
alias Shift73k.Properties
|
||||
|
||||
@impl true
|
||||
def update(%{property: property} = assigns, socket) do
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign(:changeset, Properties.change_property(property))
|
||||
|> live_okreply()
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"property" => property_params}, socket) do
|
||||
changeset =
|
||||
socket.assigns.property
|
||||
|> Properties.change_property(property_params)
|
||||
|> Map.put(:action, :validate)
|
||||
|
||||
{:noreply, assign(socket, :changeset, changeset)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("save", %{"property" => property_params}, socket) do
|
||||
save_property(socket, socket.assigns.action, property_params)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("cancel", _, socket) do
|
||||
{:noreply, push_event(socket, "modal-please-hide", %{})}
|
||||
end
|
||||
|
||||
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
|
||||
|> push_event("modal-please-hide", %{})
|
||||
|> live_noreply()
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, :changeset, changeset)}
|
||||
end
|
||||
end
|
||||
|
||||
defp save_property(socket, :new, property_params) do
|
||||
current_user = socket.assigns.current_user
|
||||
property_params = Map.put(property_params, "user_id", current_user.id)
|
||||
|
||||
case Properties.create_property(property_params) do
|
||||
{:ok, _property} ->
|
||||
flash = {:info, "Property created successfully"}
|
||||
send(self(), {:put_flash_message, flash})
|
||||
|
||||
socket
|
||||
|> push_event("modal-please-hide", %{})
|
||||
|> live_noreply()
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, changeset: changeset)}
|
||||
end
|
||||
end
|
||||
end
|
57
lib/shift73k_web/live/property_live/form_component.html.leex
Normal file
57
lib/shift73k_web/live/property_live/form_component.html.leex
Normal file
|
@ -0,0 +1,57 @@
|
|||
<%= form_for @changeset, "#", [
|
||||
id: "property-form",
|
||||
phx_target: @myself,
|
||||
phx_change: "validate",
|
||||
phx_submit: "save"
|
||||
], fn f -> %>
|
||||
|
||||
<div class="modal-body">
|
||||
|
||||
<div class="mb-3" phx-feedback-for="<%= input_id(f, :name)%>">
|
||||
<%= label f, :name, class: "form-label" %>
|
||||
<div class="input-group has-validation">
|
||||
<%= text_input f, :name,
|
||||
class: input_class(f, :name, "form-control"),
|
||||
aria_describedby: error_id(f, :name)
|
||||
%>
|
||||
<%= error_tag f, :name %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3" phx-feedback-for="<%= input_id(f, :price)%>">
|
||||
<%= label f, :price, class: "form-label" %>
|
||||
<div class="input-group has-validation">
|
||||
<%= number_input f, :price,
|
||||
class: input_class(f, :price, "form-control"),
|
||||
step: "any",
|
||||
aria_describedby: error_id(f, :price)
|
||||
%>
|
||||
<%= error_tag f, :price %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3" phx-feedback-for="<%= input_id(f, :description)%>">
|
||||
<%= label f, :description, class: "form-label" %>
|
||||
<div class="input-group has-validation">
|
||||
<%= textarea f, :description,
|
||||
class: input_class(f, :description, "form-control"),
|
||||
aria_describedby: error_id(f, :description)
|
||||
%>
|
||||
<%= error_tag f, :description %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
|
||||
<%= link "Cancel", to: "#", class: "btn btn-secondary me-2", phx_click: "cancel", phx_target: @myself %>
|
||||
<%= submit "Save",
|
||||
class: "btn btn-primary ",
|
||||
disabled: !@changeset.valid?,
|
||||
aria_disabled: !@changeset.valid? && "true" || false,
|
||||
phx_disable_with: "Saving..."
|
||||
%>
|
||||
|
||||
</div>
|
||||
|
||||
<% end %>
|
90
lib/shift73k_web/live/property_live/index.ex
Normal file
90
lib/shift73k_web/live/property_live/index.ex
Normal file
|
@ -0,0 +1,90 @@
|
|||
defmodule Shift73kWeb.PropertyLive.Index do
|
||||
use Shift73kWeb, :live_view
|
||||
|
||||
alias Shift73k.Properties
|
||||
alias Shift73k.Properties.Property
|
||||
alias Shift73kWeb.Roles
|
||||
|
||||
@impl true
|
||||
def mount(_params, session, socket) do
|
||||
socket = assign_defaults(socket, session)
|
||||
{:ok, assign(socket, :properties, [])}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(params, _url, socket) do
|
||||
current_user = socket.assigns.current_user
|
||||
live_action = socket.assigns.live_action
|
||||
property = property_from_params(params)
|
||||
|
||||
if Roles.can?(current_user, property, live_action) do
|
||||
socket
|
||||
|> assign(:properties, list_properties())
|
||||
|> assign(:modal_return_to, Routes.property_index_path(socket, :index))
|
||||
|> apply_action(live_action, params)
|
||||
|> live_noreply()
|
||||
else
|
||||
socket
|
||||
|> put_flash(:error, "Unauthorised")
|
||||
|> redirect(to: "/")
|
||||
|> live_noreply()
|
||||
end
|
||||
end
|
||||
|
||||
defp apply_action(socket, :edit, %{"id" => id}) do
|
||||
socket
|
||||
|> assign(:page_title, "Edit Property")
|
||||
|> assign(:property, Properties.get_property!(id))
|
||||
end
|
||||
|
||||
defp apply_action(socket, :new, _params) do
|
||||
socket
|
||||
|> assign(:page_title, "New Property")
|
||||
|> assign(:property, %Property{})
|
||||
end
|
||||
|
||||
defp apply_action(socket, :index, _params) do
|
||||
socket
|
||||
|> assign(:page_title, "Listing Properties")
|
||||
|> assign(:property, nil)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
current_user = socket.assigns.current_user
|
||||
property = Properties.get_property!(id)
|
||||
|
||||
if Shift73kWeb.Roles.can?(current_user, property, :delete) do
|
||||
property = Properties.get_property!(id)
|
||||
{:ok, _} = Properties.delete_property(property)
|
||||
|
||||
{:noreply, assign(socket, :properties, list_properties())}
|
||||
else
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, "Unauthorised")
|
||||
|> redirect(to: "/")}
|
||||
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}),
|
||||
do: Properties.get_property!(id)
|
||||
|
||||
defp property_from_params(_params), do: %Property{}
|
||||
|
||||
defp list_properties do
|
||||
Properties.list_properties()
|
||||
end
|
||||
end
|
46
lib/shift73k_web/live/property_live/index.html.leex
Normal file
46
lib/shift73k_web/live/property_live/index.html.leex
Normal file
|
@ -0,0 +1,46 @@
|
|||
<%= if @live_action in [:new, :edit] do %>
|
||||
<%= live_modal @socket, Shift73kWeb.PropertyLive.FormComponent,
|
||||
id: @property.id || :new,
|
||||
title: @page_title,
|
||||
action: @live_action,
|
||||
property: @property,
|
||||
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>
|
||||
<th scope="col">Name</th>
|
||||
<th scope="col">Price</th>
|
||||
<th scope="col">Description</th>
|
||||
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="properties">
|
||||
<%= for property <- @properties do %>
|
||||
<tr id="property-<%= property.id %>">
|
||||
<td><%= property.name %></td>
|
||||
<td><%= property.price %></td>
|
||||
<td><%= property.description %></td>
|
||||
|
||||
<td>
|
||||
<%= if Roles.can?(@current_user, property, :show) do %>
|
||||
<%= live_redirect "Show", to: Routes.property_show_path(@socket, :show, property), class: "link-secondary mx-1" %>
|
||||
<% end %>
|
||||
<%= if Roles.can?(@current_user, property, :edit) do %>
|
||||
<%= live_patch "Edit", to: Routes.property_index_path(@socket, :edit, property), class: "mx-1" %>
|
||||
<% end %>
|
||||
<%= if Roles.can?(@current_user, property, :delete) do %>
|
||||
<%= link "Delete", to: "#", phx_click: "delete", phx_value_id: property.id, data: [confirm: "Are you sure?"], class: "link-danger mx-1" %>
|
||||
<% end %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
45
lib/shift73k_web/live/property_live/show.ex
Normal file
45
lib/shift73k_web/live/property_live/show.ex
Normal file
|
@ -0,0 +1,45 @@
|
|||
defmodule Shift73kWeb.PropertyLive.Show do
|
||||
use Shift73kWeb, :live_view
|
||||
|
||||
alias Shift73k.Properties
|
||||
alias Shift73kWeb.Roles
|
||||
|
||||
@impl true
|
||||
def mount(_params, session, socket) do
|
||||
socket = assign_defaults(socket, session)
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(%{"id" => id}, _, socket) do
|
||||
current_user = socket.assigns.current_user
|
||||
live_action = socket.assigns.live_action
|
||||
property = Properties.get_property!(id)
|
||||
|
||||
if Roles.can?(current_user, property, live_action) do
|
||||
socket
|
||||
|> assign(:property, property)
|
||||
|> assign(:page_title, page_title(live_action))
|
||||
|> assign(:modal_return_to, Routes.property_show_path(socket, :show, property))
|
||||
|> live_noreply()
|
||||
else
|
||||
socket
|
||||
|> put_flash(:error, "Unauthorised")
|
||||
|> redirect(to: "/")
|
||||
|> live_noreply()
|
||||
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 page_title(:show), do: "Show Property"
|
||||
defp page_title(:edit), do: "Edit Property"
|
||||
end
|
33
lib/shift73k_web/live/property_live/show.html.leex
Normal file
33
lib/shift73k_web/live/property_live/show.html.leex
Normal file
|
@ -0,0 +1,33 @@
|
|||
<h2>Show Property</h2>
|
||||
|
||||
<%= if @live_action in [:edit] do %>
|
||||
<%= live_modal @socket, Shift73kWeb.PropertyLive.FormComponent,
|
||||
id: @property.id,
|
||||
title: @page_title,
|
||||
action: @live_action,
|
||||
property: @property %>
|
||||
<% end %>
|
||||
|
||||
<table class="table table-nonfluid">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="row" class="text-end">Name</th>
|
||||
<td><%= @property.name %></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row" class="text-end">Price</th>
|
||||
<td><%= @property.price %></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row" class="text-end">Description</th>
|
||||
<td><%= @property.description %></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<%= if Roles.can?(@current_user, @property, :index) do %>
|
||||
<%= live_redirect "Back", to: Routes.property_index_path(@socket, :index), class: "btn btn-secondary" %>
|
||||
<% end %>
|
||||
<%= if Roles.can?(@current_user, @property, :edit) do %>
|
||||
<%= live_patch "Edit", to: Routes.property_show_path(@socket, :edit, @property), class: "btn btn-primary" %>
|
||||
<% end %>
|
61
lib/shift73k_web/live/user/registration.ex
Normal file
61
lib/shift73k_web/live/user/registration.ex
Normal file
|
@ -0,0 +1,61 @@
|
|||
defmodule Shift73kWeb.UserLive.Registration do
|
||||
use Shift73kWeb, :live_view
|
||||
|
||||
alias Shift73k.Accounts
|
||||
alias Shift73k.Accounts.User
|
||||
|
||||
@impl true
|
||||
def mount(_params, session, socket) do
|
||||
socket
|
||||
|> assign_defaults(session)
|
||||
|> assign(page_title: "Register")
|
||||
|> assign(changeset: Accounts.change_user_registration(%User{}))
|
||||
|> assign(login_params: init_login_params(session))
|
||||
|> assign(trigger_submit: false)
|
||||
|> live_okreply()
|
||||
end
|
||||
|
||||
defp init_login_params(session) do
|
||||
%{
|
||||
user_id: nil,
|
||||
user_return_to: Map.get(session, "user_return_to", "/"),
|
||||
messages: [
|
||||
success: "Welcome! Your new account has been created, and you've been logged in.",
|
||||
info:
|
||||
"Some features may be unavailable until you confirm your email address. Check your inbox for instructions."
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"user" => user_params}, socket) do
|
||||
cs = Accounts.change_user_registration(%User{}, user_params)
|
||||
{:noreply, assign(socket, changeset: %{cs | action: :validate})}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("save", %{"user" => user_params}, socket) do
|
||||
user_params
|
||||
|> Map.put("role", Accounts.registration_role())
|
||||
|> Accounts.register_user()
|
||||
|> case do
|
||||
{:ok, user} ->
|
||||
{:ok, %Bamboo.Email{}} =
|
||||
Accounts.deliver_user_confirmation_instructions(
|
||||
user,
|
||||
&Routes.user_confirmation_url(socket, :confirm, &1)
|
||||
)
|
||||
|
||||
socket
|
||||
|> assign(login_params: %{socket.assigns.login_params | user_id: user.id})
|
||||
|> assign(trigger_submit: true)
|
||||
|> live_noreply()
|
||||
|
||||
{:error, cs} ->
|
||||
socket
|
||||
|> put_flash(:error, "Ope — registration failed for some reason.")
|
||||
|> assign(:changeset, cs)
|
||||
|> live_noreply()
|
||||
end
|
||||
end
|
||||
end
|
70
lib/shift73k_web/live/user/registration.html.leex
Normal file
70
lib/shift73k_web/live/user/registration.html.leex
Normal file
|
@ -0,0 +1,70 @@
|
|||
<div class="row justify-content-center">
|
||||
<div class="col-sm-9 col-md-7 col-lg-5 col-xl-4 ">
|
||||
|
||||
<h2>
|
||||
<%= icon_div @socket, "bi-person-plus", [class: "icon baseline"] %>
|
||||
Register
|
||||
</h2>
|
||||
<p class="lead">Registration gains additional features, like remembering your song request history.</p>
|
||||
|
||||
<%= form_for @changeset, "#", [phx_change: :validate, phx_submit: :save, novalidate: true, id: "reg_form"], 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: "blur",
|
||||
aria_describedby: error_id(f, :email)
|
||||
%>
|
||||
<%= error_tag f, :email %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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,
|
||||
phx_debounce: "250",
|
||||
aria_describedby: error_id(f, :password)
|
||||
%>
|
||||
<%= error_tag f, :password %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<%= 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 %>
|
||||
|
||||
<p>
|
||||
<%= link "Log in", to: Routes.user_session_path(@socket, :new) %> |
|
||||
<%= link "Forgot your password?", to: Routes.user_reset_password_path(@socket, :new) %>
|
||||
</p>
|
||||
|
||||
<%# hidden form for initial login after registration %>
|
||||
<%= form_for :user, Routes.user_session_path(@socket, :create), [phx_trigger_action: @trigger_submit, id: "reg_trigger"], fn f -> %>
|
||||
<%= hidden_input f, :params_token, value: Phoenix.Token.encrypt(Shift73kWeb.Endpoint, "login_params", @login_params) %>
|
||||
<% end %>
|
||||
|
||||
</div>
|
||||
</div>
|
40
lib/shift73k_web/live/user/reset_password.ex
Normal file
40
lib/shift73k_web/live/user/reset_password.ex
Normal file
|
@ -0,0 +1,40 @@
|
|||
defmodule Shift73kWeb.UserLive.ResetPassword do
|
||||
use Shift73kWeb, :live_view
|
||||
|
||||
alias Shift73k.Accounts
|
||||
alias Shift73k.Accounts.User
|
||||
|
||||
@impl true
|
||||
def mount(_params, session, socket) do
|
||||
user = Accounts.get_user!(session["user_id"])
|
||||
|
||||
socket
|
||||
|> assign_defaults(session)
|
||||
|> assign(page_title: "Reset password")
|
||||
|> assign(changeset: Accounts.change_user_password(user))
|
||||
|> assign(user: user)
|
||||
|> live_okreply()
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"user" => user_params}, socket) do
|
||||
cs = Accounts.change_user_password(socket.assigns.user, user_params)
|
||||
{:noreply, socket |> assign(changeset: %{cs | action: :validate})}
|
||||
end
|
||||
|
||||
def handle_event("save", %{"user" => user_params}, socket) do
|
||||
case Accounts.reset_user_password(socket.assigns.user, user_params) do
|
||||
{:ok, _} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Password reset successfully.")
|
||||
|> redirect(to: Routes.user_session_path(socket, :new))}
|
||||
|
||||
{:error, changeset} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, "Please check the errors below.")
|
||||
|> assign(changeset: changeset)}
|
||||
end
|
||||
end
|
||||
end
|
62
lib/shift73k_web/live/user/reset_password.html.leex
Normal file
62
lib/shift73k_web/live/user/reset_password.html.leex
Normal file
|
@ -0,0 +1,62 @@
|
|||
<div class="row justify-content-center">
|
||||
<div class="col-sm-9 col-md-7 col-lg-5 col-xl-4 ">
|
||||
|
||||
<h2>
|
||||
<%= icon_div @socket, "bi-shield-lock", [class: "icon baseline"] %>
|
||||
Reset password
|
||||
</h2>
|
||||
<p class="lead">Hi <%= @user.email %> — What new word of passage will confirm you are who you say you are?</p>
|
||||
|
||||
<%= form_for @changeset, "#", [phx_change: :validate, phx_submit: :save, novalidate: true, id: "pw_reset_form"], fn f -> %>
|
||||
|
||||
<div class="mb-3" phx-feedback-for="<%= input_id(f, :password) %>">
|
||||
<%= 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"] %>
|
||||
</span>
|
||||
<%= password_input f, :password,
|
||||
value: input_value(f, :password),
|
||||
class: input_class(f, :password, "form-control"),
|
||||
maxlength: User.max_password,
|
||||
autofocus: true,
|
||||
aria_describedby: error_id(f, :password)
|
||||
%>
|
||||
<%= error_tag f, :password %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3" phx-feedback-for="<%= input_id(f, :password_confirmation) %>">
|
||||
<%= 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"] %>
|
||||
</span>
|
||||
<%= password_input f, :password_confirmation,
|
||||
value: input_value(f, :password_confirmation),
|
||||
class: input_class(f, :password_confirmation, "form-control"),
|
||||
maxlength: User.max_password,
|
||||
aria_describedby: error_id(f, :password_confirmation)
|
||||
%>
|
||||
<%= error_tag f, :password_confirmation %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<%= submit "Reset password",
|
||||
class: "btn btn-primary",
|
||||
disabled: !@changeset.valid?,
|
||||
aria_disabled: !@changeset.valid? && "true" || false,
|
||||
phx_disable_with: "Saving..."
|
||||
%>
|
||||
</div>
|
||||
|
||||
<% end %>
|
||||
|
||||
<p class="mt-3 is-pulled-right">
|
||||
<%= link "Register", to: Routes.user_registration_path(@socket, :new) %> |
|
||||
<%= link "Log in", to: Routes.user_session_path(@socket, :new) %>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</div>
|
39
lib/shift73k_web/live/user/settings.ex
Normal file
39
lib/shift73k_web/live/user/settings.ex
Normal file
|
@ -0,0 +1,39 @@
|
|||
defmodule Shift73kWeb.UserLive.Settings do
|
||||
use Shift73kWeb, :live_view
|
||||
|
||||
alias Shift73k.Accounts.User
|
||||
|
||||
@impl true
|
||||
def mount(_params, session, socket) do
|
||||
socket
|
||||
|> assign_defaults(session)
|
||||
|> alert_email_verified?()
|
||||
|> live_okreply()
|
||||
end
|
||||
|
||||
defp alert_email_verified?(socket) do
|
||||
case socket.assigns.current_user do
|
||||
%{confirmed_at: nil} ->
|
||||
put_flash(socket, :warning, [
|
||||
"Your email hasn't been confirmed, some areas may be restricted. Shall we ",
|
||||
link("resend the verification email?",
|
||||
to: Routes.user_confirmation_path(socket, :new),
|
||||
class: "alert-link"
|
||||
)
|
||||
])
|
||||
|
||||
_ ->
|
||||
socket
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:put_flash_message, {flash_type, msg}}, socket) do
|
||||
socket |> put_flash(flash_type, msg) |> live_noreply()
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:clear_flash_message, flash_type}, socket) do
|
||||
socket |> clear_flash(flash_type) |> live_noreply()
|
||||
end
|
||||
end
|
9
lib/shift73k_web/live/user/settings.html.leex
Normal file
9
lib/shift73k_web/live/user/settings.html.leex
Normal file
|
@ -0,0 +1,9 @@
|
|||
<h2 class="mb-3">
|
||||
<%= icon_div @socket, "bi-sliders", [class: "icon baseline"] %>
|
||||
User Settings
|
||||
</h2>
|
||||
|
||||
<div class="row">
|
||||
<%= live_component @socket, Shift73kWeb.UserLive.Settings.Email, id: "email-#{@current_user.id}", current_user: @current_user %>
|
||||
<%= live_component @socket, Shift73kWeb.UserLive.Settings.Password, id: "password-#{@current_user.id}", current_user: @current_user %>
|
||||
</div>
|
62
lib/shift73k_web/live/user/settings/email.ex
Normal file
62
lib/shift73k_web/live/user/settings/email.ex
Normal file
|
@ -0,0 +1,62 @@
|
|||
defmodule Shift73kWeb.UserLive.Settings.Email do
|
||||
use Shift73kWeb, :live_component
|
||||
|
||||
alias Shift73k.Accounts
|
||||
alias Shift73k.Accounts.User
|
||||
|
||||
@impl true
|
||||
def update(%{current_user: user} = assigns, socket) do
|
||||
socket
|
||||
|> assign(id: assigns.id)
|
||||
|> assign(current_user: user)
|
||||
|> assign(changeset: get_changeset(user))
|
||||
|> live_okreply()
|
||||
end
|
||||
|
||||
defp get_changeset(user, user_params \\ %{}) do
|
||||
Accounts.change_user_email(user, user_params)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"user" => user_params}, socket) do
|
||||
cs = get_changeset(socket.assigns.current_user, user_params)
|
||||
{:noreply, assign(socket, changeset: %{cs | action: :validate})}
|
||||
end
|
||||
|
||||
# user_settings_path GET /users/settings/confirm_email/:token Shift73kWeb.UserSettingsController :confirm_email
|
||||
|
||||
@impl true
|
||||
def handle_event("save", %{"user" => user_params}, socket) do
|
||||
case Accounts.apply_user_email(socket.assigns.current_user, user_params) do
|
||||
{:ok, applied_user} ->
|
||||
Accounts.deliver_update_email_instructions(
|
||||
applied_user,
|
||||
socket.assigns.current_user.email,
|
||||
&Routes.user_settings_url(socket, :confirm_email, &1)
|
||||
)
|
||||
|
||||
send(self(), {:clear_flash_message, :error})
|
||||
|
||||
send(
|
||||
self(),
|
||||
{:put_flash_message,
|
||||
{:info, "A link to confirm your e-mail change has been sent to the new address."}}
|
||||
)
|
||||
|
||||
socket
|
||||
|> assign(changeset: get_changeset(socket.assigns.current_user))
|
||||
|> live_noreply()
|
||||
|
||||
{:error, cs} ->
|
||||
cu = socket.assigns.current_user
|
||||
cpw = user_params["current_password"]
|
||||
valid_password? = User.valid_password?(cu, cpw)
|
||||
msg = (valid_password? && "Could not reset email.") || "Invalid current password."
|
||||
send(self(), {:put_flash_message, {:error, msg}})
|
||||
|
||||
socket
|
||||
|> assign(changeset: cs)
|
||||
|> live_noreply()
|
||||
end
|
||||
end
|
||||
end
|
52
lib/shift73k_web/live/user/settings/email.html.leex
Normal file
52
lib/shift73k_web/live/user/settings/email.html.leex
Normal file
|
@ -0,0 +1,52 @@
|
|||
<div id="<%= @id %>" class="col-sm-9 col-md-7 col-lg-5 col-xl-4 mt-1">
|
||||
|
||||
<h3>Change email</h3>
|
||||
|
||||
<%= form_for @changeset, "#", [phx_change: :validate, phx_submit: :save, phx_target: @myself], 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,
|
||||
phx_debounce: "500",
|
||||
aria_describedby: error_id(f, :email)
|
||||
%>
|
||||
<%= error_tag f, :email %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="mb-3" phx-feedback-for="<%= input_id(f, :current_password) %>">
|
||||
<%= label f, :current_password, class: "form-label" %>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">
|
||||
<%= icon_div @socket, "bi-lock", [class: "icon"] %>
|
||||
</span>
|
||||
<%= password_input f, :current_password,
|
||||
value: input_value(f, :current_password),
|
||||
class: "form-control",
|
||||
aria_describedby: error_id(f, :current_password)
|
||||
%>
|
||||
<%= error_tag f, :current_password %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<%= submit "Change email",
|
||||
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>
|
||||
|
||||
<% end %>
|
||||
|
||||
</div>
|
57
lib/shift73k_web/live/user/settings/password.ex
Normal file
57
lib/shift73k_web/live/user/settings/password.ex
Normal file
|
@ -0,0 +1,57 @@
|
|||
defmodule Shift73kWeb.UserLive.Settings.Password do
|
||||
use Shift73kWeb, :live_component
|
||||
|
||||
alias Shift73k.Accounts
|
||||
alias Shift73k.Accounts.User
|
||||
|
||||
@impl true
|
||||
def update(%{current_user: user} = assigns, socket) do
|
||||
socket
|
||||
|> assign(id: assigns.id)
|
||||
|> assign(current_user: user)
|
||||
|> assign(changeset: get_changeset(user))
|
||||
|> assign(login_params: init_login_params(socket))
|
||||
|> assign(trigger_submit: false)
|
||||
|> live_okreply()
|
||||
end
|
||||
|
||||
defp get_changeset(user, user_params \\ %{}) do
|
||||
Accounts.change_user_password(user, user_params)
|
||||
end
|
||||
|
||||
defp init_login_params(socket) do
|
||||
%{
|
||||
user_id: nil,
|
||||
user_return_to: Routes.user_settings_path(socket, :edit),
|
||||
messages: [info: "Password updated successfully."]
|
||||
}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"user" => user_params}, socket) do
|
||||
cs = get_changeset(socket.assigns.current_user, user_params)
|
||||
{:noreply, assign(socket, changeset: %{cs | action: :validate})}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("save", %{"user" => user_params}, socket) do
|
||||
case Accounts.update_user_password(socket.assigns.current_user, user_params) do
|
||||
{:ok, user} ->
|
||||
socket
|
||||
|> assign(login_params: %{socket.assigns.login_params | user_id: user.id})
|
||||
|> assign(trigger_submit: true)
|
||||
|> live_noreply()
|
||||
|
||||
{:error, cs} ->
|
||||
cu = socket.assigns.current_user
|
||||
cpw = user_params["current_password"]
|
||||
valid_password? = User.valid_password?(cu, cpw)
|
||||
msg = (valid_password? && "Could not change password.") || "Invalid current password."
|
||||
send(self(), {:put_flash_message, {:error, msg}})
|
||||
|
||||
socket
|
||||
|> assign(changeset: cs)
|
||||
|> live_noreply()
|
||||
end
|
||||
end
|
||||
end
|
77
lib/shift73k_web/live/user/settings/password.html.leex
Normal file
77
lib/shift73k_web/live/user/settings/password.html.leex
Normal file
|
@ -0,0 +1,77 @@
|
|||
<div id="<%= @id %>" class="col-sm-9 col-md-7 col-lg-5 col-xl-4 mt-1">
|
||||
|
||||
<h3>Change password</h3>
|
||||
|
||||
<%= form_for @changeset, "#", [phx_change: :validate, phx_submit: :save, phx_target: @myself], fn f -> %>
|
||||
|
||||
<div class="mb-3" phx-feedback-for="<%= input_id(f, :password) %>">
|
||||
<%= 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"] %>
|
||||
</span>
|
||||
<%= password_input f, :password,
|
||||
value: input_value(f, :password),
|
||||
class: input_class(f, :password, "form-control"),
|
||||
maxlength: User.max_password,
|
||||
phx_debounce: "500",
|
||||
aria_describedby: error_id(f, :password)
|
||||
%>
|
||||
<%= error_tag f, :password %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3" phx-feedback-for="<%= input_id(f, :password_confirmation) %>">
|
||||
<%= 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"] %>
|
||||
</span>
|
||||
<%= password_input f, :password_confirmation,
|
||||
value: input_value(f, :password_confirmation),
|
||||
class: input_class(f, :password_confirmation, "form-control"),
|
||||
maxlength: User.max_password,
|
||||
aria_describedby: error_id(f, :password_confirmation)
|
||||
%>
|
||||
<%= error_tag f, :password_confirmation %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3" phx-feedback-for="<%= input_id(f, :current_password) %>">
|
||||
<%= label f, :current_password, class: "form-label" %>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">
|
||||
<%= icon_div @socket, "bi-lock", [class: "icon"] %>
|
||||
</span>
|
||||
<%= password_input f, :current_password,
|
||||
value: input_value(f, :current_password),
|
||||
class: "form-control",
|
||||
aria_describedby: error_id(f, :current_password)
|
||||
%>
|
||||
<%= error_tag f, :current_password %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<%= submit "Change 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>
|
||||
|
||||
<% end %>
|
||||
|
||||
<%# hidden form for initial login after registration %>
|
||||
<%= form_for :user, Routes.user_session_path(@socket, :create), [phx_trigger_action: @trigger_submit, id: "settings_pw_change_trigger"], fn f -> %>
|
||||
<%= hidden_input f, :params_token, value: Phoenix.Token.encrypt(Shift73kWeb.Endpoint, "login_params", @login_params) %>
|
||||
<% end %>
|
||||
|
||||
<%# hidden form to submit user for relogin after password change %>
|
||||
<%#= form_for :user_login, Routes.user_session_path(@socket, :create), [phx_trigger_action: @trigger_submit], fn f -> %>
|
||||
<%#= hidden_input f, :login_params_token, value: Phoenix.Token.encrypt(Shift73kWeb.Endpoint, "login_params", @login_params) %>
|
||||
<%#= hidden_input f, :remember_me, value: false %>
|
||||
<%# end %>
|
||||
|
||||
</div>
|
18
lib/shift73k_web/live/user_dashboard_live.ex
Normal file
18
lib/shift73k_web/live/user_dashboard_live.ex
Normal file
|
@ -0,0 +1,18 @@
|
|||
defmodule Shift73kWeb.UserDashboardLive do
|
||||
use Shift73kWeb, :live_view
|
||||
|
||||
@impl true
|
||||
def mount(_params, session, socket) do
|
||||
socket = assign_defaults(socket, session)
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~L"""
|
||||
<section class="phx-hero">
|
||||
<h2>Welcome to the user dashboard!</h2>
|
||||
</section>
|
||||
"""
|
||||
end
|
||||
end
|
44
lib/shift73k_web/live/user_management/delete_component.ex
Normal file
44
lib/shift73k_web/live/user_management/delete_component.ex
Normal file
|
@ -0,0 +1,44 @@
|
|||
defmodule Shift73kWeb.UserManagement.DeleteComponent do
|
||||
use Shift73kWeb, :live_component
|
||||
|
||||
alias Shift73k.Accounts
|
||||
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> live_okreply()
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("cancel", _, socket) do
|
||||
{:noreply, push_event(socket, "modal-please-hide", %{})}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("confirm", %{"id" => id, "email" => email}, socket) do
|
||||
id
|
||||
|> Accounts.get_user()
|
||||
|> Accounts.delete_user()
|
||||
|> case do
|
||||
{:ok, _} ->
|
||||
flash = {:info, "User deleted successfully: \"#{email}\""}
|
||||
send(self(), {:put_flash_message, flash})
|
||||
|
||||
socket
|
||||
|> push_event("modal-please-hide", %{})
|
||||
|> live_noreply()
|
||||
|
||||
{:error, _} ->
|
||||
flash =
|
||||
{:error,
|
||||
"Some error trying to delete user \"#{email}\". Possibly already deleted? Reloading list..."}
|
||||
|
||||
send(self(), {:put_flash_message, flash})
|
||||
|
||||
socket
|
||||
|> push_event("modal-please-hide", %{})
|
||||
|> live_noreply()
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,16 @@
|
|||
<div class="modal-body">
|
||||
|
||||
Are you sure you want to delete "<%= @delete_user.email %>"?
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
|
||||
<%= link "Cancel", to: "#", class: "btn btn-secondary me-2", phx_click: "cancel", phx_target: @myself %>
|
||||
<%= link "Confirm Delete", to: "#",
|
||||
class: "btn btn-danger",
|
||||
phx_click: "confirm",
|
||||
phx_target: @myself,
|
||||
phx_value_id: @delete_user.id,
|
||||
phx_value_email: @delete_user.email %>
|
||||
|
||||
</div>
|
95
lib/shift73k_web/live/user_management/form_component.ex
Normal file
95
lib/shift73k_web/live/user_management/form_component.ex
Normal file
|
@ -0,0 +1,95 @@
|
|||
defmodule Shift73kWeb.UserManagement.FormComponent do
|
||||
use Shift73kWeb, :live_component
|
||||
|
||||
alias Shift73k.Accounts
|
||||
alias Shift73k.Accounts.User
|
||||
alias Shift73kWeb.Roles
|
||||
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> init_changeset(assigns)
|
||||
|> 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 = {:info, "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
|
||||
|
||||
@impl true
|
||||
def handle_event("cancel", _, socket) do
|
||||
{:noreply, push_event(socket, "modal-please-hide", %{})}
|
||||
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
|
|
@ -0,0 +1,72 @@
|
|||
<%= form_for @changeset, "#", [
|
||||
phx_target: @myself,
|
||||
phx_change: "validate",
|
||||
phx_submit: "save"
|
||||
], fn f -> %>
|
||||
|
||||
<div class="modal-body">
|
||||
|
||||
<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 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>
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
|
||||
<%= link "Cancel", to: "#", class: "btn btn-secondary", phx_click: "cancel", phx_target: @myself %>
|
||||
<%= submit "Save",
|
||||
class: "btn btn-primary ",
|
||||
disabled: !@changeset.valid?,
|
||||
aria_disabled: !@changeset.valid? && "true" || false,
|
||||
phx_disable_with: "Saving..."
|
||||
%>
|
||||
|
||||
</div>
|
||||
|
||||
<% end %>
|
191
lib/shift73k_web/live/user_management/index.ex
Normal file
191
lib/shift73k_web/live/user_management/index.ex
Normal file
|
@ -0,0 +1,191 @@
|
|||
defmodule Shift73kWeb.UserManagementLive.Index do
|
||||
use Shift73kWeb, :live_view
|
||||
|
||||
import Ecto.Query
|
||||
import Shift73kWeb.Pagination
|
||||
import Shift73k.Util.Dt
|
||||
|
||||
alias Shift73k.Repo
|
||||
alias Shift73k.Accounts
|
||||
alias Shift73k.Accounts.User
|
||||
alias Shift73kWeb.Roles
|
||||
|
||||
@impl true
|
||||
def mount(_params, session, socket) do
|
||||
socket
|
||||
|> assign_defaults(session)
|
||||
|> assign(:page, nil)
|
||||
|> 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()
|
||||
|> assign(:delete_user, nil)
|
||||
|> assign(:page, nil)
|
||||
|> request_page_query()
|
||||
|> apply_action(socket.assigns.live_action, params)
|
||||
|> live_noreply()
|
||||
else
|
||||
socket
|
||||
|> put_flash(:error, "Unauthorised")
|
||||
|> redirect(to: "/")
|
||||
|> live_noreply()
|
||||
end
|
||||
end
|
||||
|
||||
defp request_page_query(%{assigns: %{query: query}} = socket) do
|
||||
send(self(), {:do_page_query, query})
|
||||
socket
|
||||
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(query) do
|
||||
from(u in User)
|
||||
|> or_where([u], ilike(u.email, ^"%#{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)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("delete-modal", %{"id" => id}, socket) do
|
||||
{:noreply, assign(socket, :delete_user, Accounts.get_user(id))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("filter-change", params, socket) do
|
||||
send(self(), {:query_update, Map.put(params, "page_number", "1")})
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("filter-clear", _params, socket) do
|
||||
send(self(), {:query_update, %{"filter" => "", "page_number" => "1"}})
|
||||
{:noreply, socket}
|
||||
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, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("sort-by-change", %{"sort" => params}, socket) do
|
||||
send(self(), {:query_update, params})
|
||||
{:noreply, socket}
|
||||
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, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("page-change", params, socket) do
|
||||
send(self(), {:query_update, params})
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("page-size-change", params, socket) do
|
||||
send(self(), {:query_update, Map.put(params, "page_number", "1")})
|
||||
{:noreply, socket}
|
||||
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({:do_page_query, query}, socket) do
|
||||
{:noreply, assign(socket, :page, page_query(query))}
|
||||
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
|
216
lib/shift73k_web/live/user_management/index.html.leex
Normal file
216
lib/shift73k_web/live/user_management/index.html.leex
Normal file
|
@ -0,0 +1,216 @@
|
|||
<%= if @live_action in [:new, :edit] do %>
|
||||
<%= live_modal @socket, Shift73kWeb.UserManagement.FormComponent,
|
||||
id: @user.id || :new,
|
||||
title: @page_title,
|
||||
action: @live_action,
|
||||
user: @user,
|
||||
current_user: @current_user %>
|
||||
<% end %>
|
||||
|
||||
<%= if @delete_user do %>
|
||||
<%= live_modal @socket, Shift73kWeb.UserManagement.DeleteComponent,
|
||||
id: @delete_user.id,
|
||||
title: "Delete User",
|
||||
delete_user: @delete_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 mb-3">
|
||||
|
||||
<%= live_patch to: Routes.user_management_index_path(@socket, :new, Enum.into(@query, [])),
|
||||
class: "btn btn-primary mb-3 mb-sm-0" 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">
|
||||
|
||||
<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 %>
|
||||
<div class="table-responsive">
|
||||
<table class="table">
|
||||
|
||||
<thead>
|
||||
<tr>
|
||||
|
||||
<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">
|
||||
<%= if !@page do %>
|
||||
<tr>
|
||||
<td class="text-center" colspan="5">
|
||||
<div class="spinner-border text-primary my-5" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% else %>
|
||||
<%= 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 text-nowrap">
|
||||
|
||||
<%= live_patch to: Routes.user_management_index_path(@socket, :edit, user.id, Enum.into(@query, [])), class: "btn btn-outline-primary btn-sm text-nowrap" do %>
|
||||
<%= icon_div @socket, "bi-pencil", [class: "icon baseline", style: "margin-right:0.125rem;"] %>
|
||||
Edit
|
||||
<% end %>
|
||||
|
||||
<%= if Roles.can?(@current_user, user, :delete) do %>
|
||||
|
||||
<button class="btn btn-outline-danger btn-sm text-nowrap" phx-click="delete-modal" phx-value-id="<%= user.id %>">
|
||||
<%= icon_div @socket, "bi-trash", [class: "icon baseline", style: "margin-right:0.125rem;"] %>
|
||||
Delete
|
||||
</button>
|
||||
|
||||
<% end %>
|
||||
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</tbody>
|
||||
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<%# pagination interface %>
|
||||
<%= if @page do %>
|
||||
<div class="d-flex flex-column flex-sm-row justify-content-between d-flex align-items-start my-3">
|
||||
|
||||
<%# <div class="d-flex justify-content-between d-flex align-items-start"> %>
|
||||
|
||||
<%# items per page selector %>
|
||||
<div class="d-flex align-items-center mb-3 mb-sm-0">
|
||||
<%= 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">…</span>
|
||||
<span class="visually-hidden" role="img" aria-label="ellipses">…</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>
|
||||
<% end %>
|
52
lib/shift73k_web/plugs/ensure_role_plug.ex
Normal file
52
lib/shift73k_web/plugs/ensure_role_plug.ex
Normal file
|
@ -0,0 +1,52 @@
|
|||
defmodule Shift73kWeb.EnsureRolePlug do
|
||||
@moduledoc """
|
||||
This plug ensures that a user has a particular role before accessing a given route.
|
||||
|
||||
## Example
|
||||
Let's suppose we have three roles: :admin, :manager and :user.
|
||||
|
||||
If you want a user to have at least manager role, so admins and managers are authorised to access a given route
|
||||
plug Shift73kWeb.EnsureRolePlug, [:admin, :manager]
|
||||
|
||||
If you want to give access only to an admin:
|
||||
plug Shift73kWeb.EnsureRolePlug, :admin
|
||||
"""
|
||||
import Plug.Conn
|
||||
|
||||
alias Shift73k.Accounts
|
||||
alias Shift73k.Accounts.User
|
||||
alias Phoenix.Controller
|
||||
alias Plug.Conn
|
||||
|
||||
@doc false
|
||||
@spec init(any()) :: any()
|
||||
def init(config), do: config
|
||||
|
||||
@doc false
|
||||
@spec call(Conn.t(), atom() | [atom()]) :: Conn.t()
|
||||
def call(conn, roles) do
|
||||
user_token = get_session(conn, :user_token)
|
||||
|
||||
(user_token &&
|
||||
Accounts.get_user_by_session_token(user_token))
|
||||
|> has_role?(roles)
|
||||
|> maybe_halt(conn)
|
||||
end
|
||||
|
||||
defp has_role?(%User{} = user, roles) when is_list(roles),
|
||||
do: Enum.any?(roles, &has_role?(user, &1))
|
||||
|
||||
defp has_role?(%User{role: role}, role), do: true
|
||||
defp has_role?(_user, _role), do: false
|
||||
|
||||
defp maybe_halt(true, conn), do: conn
|
||||
|
||||
defp maybe_halt(_any, conn) do
|
||||
conn
|
||||
|> Controller.put_flash(:error, "Unauthorised")
|
||||
|> Controller.redirect(to: signed_in_path(conn))
|
||||
|> halt()
|
||||
end
|
||||
|
||||
defp signed_in_path(_conn), do: "/"
|
||||
end
|
32
lib/shift73k_web/roles.ex
Normal file
32
lib/shift73k_web/roles.ex
Normal file
|
@ -0,0 +1,32 @@
|
|||
defmodule Shift73kWeb.Roles do
|
||||
@moduledoc """
|
||||
Defines roles related functions.
|
||||
"""
|
||||
|
||||
alias Shift73k.Accounts.User
|
||||
alias Shift73k.Properties.Property
|
||||
|
||||
@type entity :: struct()
|
||||
@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
|
118
lib/shift73k_web/router.ex
Normal file
118
lib/shift73k_web/router.ex
Normal file
|
@ -0,0 +1,118 @@
|
|||
defmodule Shift73kWeb.Router do
|
||||
use Shift73kWeb, :router
|
||||
import Shift73kWeb.UserAuth
|
||||
alias Shift73kWeb.EnsureRolePlug
|
||||
|
||||
pipeline :browser do
|
||||
plug(:accepts, ["html"])
|
||||
plug(:fetch_session)
|
||||
plug(:fetch_live_flash)
|
||||
plug(:put_root_layout, {Shift73kWeb.LayoutView, :root})
|
||||
plug(:protect_from_forgery)
|
||||
plug(:put_secure_browser_headers)
|
||||
plug(:fetch_current_user)
|
||||
end
|
||||
|
||||
pipeline :api do
|
||||
plug(:accepts, ["json"])
|
||||
end
|
||||
|
||||
pipeline :user do
|
||||
plug(EnsureRolePlug, [:admin, :manager, :user])
|
||||
end
|
||||
|
||||
pipeline :manager do
|
||||
plug(EnsureRolePlug, [:admin, :manager])
|
||||
end
|
||||
|
||||
pipeline :admin do
|
||||
plug(EnsureRolePlug, :admin)
|
||||
end
|
||||
|
||||
scope "/", Shift73kWeb do
|
||||
pipe_through([:browser])
|
||||
|
||||
live("/", PageLive, :index)
|
||||
get("/other", OtherController, :index)
|
||||
end
|
||||
|
||||
# Other scopes may use custom stacks.
|
||||
# scope "/api", Shift73kWeb do
|
||||
# pipe_through :api
|
||||
# end
|
||||
|
||||
# Enables LiveDashboard only for development
|
||||
#
|
||||
# If you want to use the LiveDashboard in production, you should put
|
||||
# it behind authentication and allow only admins to access it.
|
||||
# If your application does not have an admins-only section yet,
|
||||
# you can use Plug.BasicAuth to set up some basic authentication
|
||||
# as long as you are also using SSL (which you should anyway).
|
||||
if Mix.env() in [:dev, :test] do
|
||||
import Phoenix.LiveDashboard.Router
|
||||
|
||||
scope "/" do
|
||||
pipe_through(:browser)
|
||||
live_dashboard("/dashboard", metrics: Shift73kWeb.Telemetry)
|
||||
end
|
||||
end
|
||||
|
||||
scope "/", Shift73kWeb do
|
||||
pipe_through([:browser, :redirect_if_user_is_authenticated])
|
||||
|
||||
get("/users/register", UserRegistrationController, :new)
|
||||
get("/users/log_in", UserSessionController, :new)
|
||||
post("/users/log_in", UserSessionController, :create)
|
||||
get("/users/reset_password", UserResetPasswordController, :new)
|
||||
post("/users/reset_password", UserResetPasswordController, :create)
|
||||
get("/users/reset_password/:token", UserResetPasswordController, :edit)
|
||||
end
|
||||
|
||||
scope "/", Shift73kWeb do
|
||||
pipe_through([:browser, :require_authenticated_user])
|
||||
|
||||
# # liveview user settings
|
||||
live("/users/settings", UserLive.Settings, :edit)
|
||||
|
||||
# original user routes from phx.gen.auth
|
||||
get("/users/settings/confirm_email/:token", UserSettingsController, :confirm_email)
|
||||
end
|
||||
|
||||
scope "/", Shift73kWeb do
|
||||
pipe_through([:browser])
|
||||
|
||||
delete("/users/log_out", UserSessionController, :delete)
|
||||
get("/users/force_logout", UserSessionController, :force_logout)
|
||||
get("/users/confirm", UserConfirmationController, :new)
|
||||
post("/users/confirm", UserConfirmationController, :create)
|
||||
get("/users/confirm/:token", UserConfirmationController, :confirm)
|
||||
end
|
||||
|
||||
scope "/", Shift73kWeb do
|
||||
pipe_through([:browser, :require_authenticated_user, :user])
|
||||
|
||||
live("/user_dashboard", UserDashboardLive, :index)
|
||||
|
||||
live("/properties", PropertyLive.Index, :index)
|
||||
live("/properties/new", PropertyLive.Index, :new)
|
||||
live("/properties/:id/edit", PropertyLive.Index, :edit)
|
||||
live("/properties/:id", PropertyLive.Show, :show)
|
||||
live("/properties/:id/show/edit", PropertyLive.Show, :edit)
|
||||
end
|
||||
|
||||
scope "/", Shift73kWeb do
|
||||
pipe_through([:browser, :require_authenticated_user, :admin])
|
||||
|
||||
live("/admin_dashboard", AdminDashboardLive, :index)
|
||||
end
|
||||
|
||||
# Users Management
|
||||
scope "/users", Shift73kWeb 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
|
55
lib/shift73k_web/telemetry.ex
Normal file
55
lib/shift73k_web/telemetry.ex
Normal file
|
@ -0,0 +1,55 @@
|
|||
defmodule Shift73kWeb.Telemetry do
|
||||
use Supervisor
|
||||
import Telemetry.Metrics
|
||||
|
||||
def start_link(arg) do
|
||||
Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(_arg) do
|
||||
children = [
|
||||
# Telemetry poller will execute the given period measurements
|
||||
# every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics
|
||||
{:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
|
||||
# Add reporters as children of your supervision tree.
|
||||
# {Telemetry.Metrics.ConsoleReporter, metrics: metrics()}
|
||||
]
|
||||
|
||||
Supervisor.init(children, strategy: :one_for_one)
|
||||
end
|
||||
|
||||
def metrics do
|
||||
[
|
||||
# Phoenix Metrics
|
||||
summary("phoenix.endpoint.stop.duration",
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
summary("phoenix.router_dispatch.stop.duration",
|
||||
tags: [:route],
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
|
||||
# Database Metrics
|
||||
summary("shift73k.repo.query.total_time", unit: {:native, :millisecond}),
|
||||
summary("shift73k.repo.query.decode_time", unit: {:native, :millisecond}),
|
||||
summary("shift73k.repo.query.query_time", unit: {:native, :millisecond}),
|
||||
summary("shift73k.repo.query.queue_time", unit: {:native, :millisecond}),
|
||||
summary("shift73k.repo.query.idle_time", unit: {:native, :millisecond}),
|
||||
|
||||
# VM Metrics
|
||||
summary("vm.memory.total", unit: {:byte, :kilobyte}),
|
||||
summary("vm.total_run_queue_lengths.total"),
|
||||
summary("vm.total_run_queue_lengths.cpu"),
|
||||
summary("vm.total_run_queue_lengths.io")
|
||||
]
|
||||
end
|
||||
|
||||
defp periodic_measurements do
|
||||
[
|
||||
# A module, function and arguments to be invoked periodically.
|
||||
# This function must call :telemetry.execute/3 and a metric must be added above.
|
||||
# {Shift73kWeb, :count_users, []}
|
||||
]
|
||||
end
|
||||
end
|
57
lib/shift73k_web/templates/layout/_navbar.html.eex
Normal file
57
lib/shift73k_web/templates/layout/_navbar.html.eex
Normal file
|
@ -0,0 +1,57 @@
|
|||
<nav class="navbar navbar-expand-lg navbar-light bg-light mb-4">
|
||||
<div class="container">
|
||||
|
||||
<h1 class="fs-4 my-0 py-0 lh-base">
|
||||
<%= link to: Routes.page_path(@conn, :index), class: "navbar-brand fs-4" do %>
|
||||
<%= icon_div @conn, "mdi-skull-crossbones", [class: "icon baseline fs-3"] %>
|
||||
<span class="fw-light">Shift73k</span>
|
||||
<% end %>
|
||||
</h1>
|
||||
|
||||
<button class="hamburger hamburger--squeeze collapsed navbar-toggler" id="navbarSupportedContentToggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="hamburger-box d-flex">
|
||||
<span class="hamburger-inner"></span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="collapse navbar-collapse" id="navbarSupportedContent">
|
||||
|
||||
<%# nav LEFT items %>
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||
|
||||
<li class="nav-item">
|
||||
<%= link "Properties", nav_link_opts(@conn, to: Routes.property_index_path(@conn, :index), class: "nav-link") %>
|
||||
</li>
|
||||
|
||||
<%# ACTIVE page link example %>
|
||||
<%# <li class="nav-item">
|
||||
<a class="nav-link active" aria-current="page" href="#">Home</a>
|
||||
</li> %>
|
||||
|
||||
<%# DISABLED page link example %>
|
||||
<%# <li class="nav-item">
|
||||
<a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Disabled</a>
|
||||
</li> %>
|
||||
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdownExample" data-bs-toggle="dropdown" aria-expanded="false">Dropdown</a>
|
||||
<ul class="dropdown-menu" aria-labelledby="navbarDropdownExample">
|
||||
<li><a class="dropdown-item" href="#">Action</a></li>
|
||||
<li><a class="dropdown-item" href="#">Another action</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="#">Something else here</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
|
||||
<%# nav RIGHT items %>
|
||||
<ul class="navbar-nav">
|
||||
|
||||
<%= render "navbar/_user_menu.html", assigns %>
|
||||
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
19
lib/shift73k_web/templates/layout/app.html.eex
Normal file
19
lib/shift73k_web/templates/layout/app.html.eex
Normal file
|
@ -0,0 +1,19 @@
|
|||
<main role="main" class="container">
|
||||
|
||||
<%# phoenix flash alerts: %>
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-12 col-lg-10 col-xl-9 ">
|
||||
<%= for {kind, class} <- alert_kinds() do %>
|
||||
<%= if flash_content = get_flash(@conn, kind) do %>
|
||||
<div class="alert <%= class %> alert-dismissible fade show" role="alert">
|
||||
<%= flash_content %>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= @inner_content %>
|
||||
|
||||
</main>
|
19
lib/shift73k_web/templates/layout/live.html.leex
Normal file
19
lib/shift73k_web/templates/layout/live.html.leex
Normal file
|
@ -0,0 +1,19 @@
|
|||
<main role="main" class="container">
|
||||
|
||||
<%# liveview flash alerts: %>
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-12 col-lg-10 col-xl-9 ">
|
||||
<%= for {kind, class} <- alert_kinds() do %>
|
||||
<%= if flash_content = live_flash(@flash, kind) do %>
|
||||
<div class="alert <%= class %> alert-dismissible fade show" role="alert" id="lv-alert-<%= kind %>" phx-hook="AlertRemover" data-key="<%= kind %>">
|
||||
<%= flash_content %>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= @inner_content %>
|
||||
|
||||
</main>
|
54
lib/shift73k_web/templates/layout/navbar/_user_menu.html.eex
Normal file
54
lib/shift73k_web/templates/layout/navbar/_user_menu.html.eex
Normal file
|
@ -0,0 +1,54 @@
|
|||
<li class="nav-item dropdown">
|
||||
|
||||
<a href="#" class="nav-link dropdown-toggle" id="navbarDropdownUserMenu" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<%= icon_div @conn, "bi-person-circle", [class: "icon baseline me-1"] %>
|
||||
<%= @current_user && "Hello!" || "Hello?" %>
|
||||
</a>
|
||||
|
||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="navbarDropdownUserMenu">
|
||||
|
||||
<%= if @current_user do %>
|
||||
|
||||
<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"] %>
|
||||
Settings
|
||||
<% end %>
|
||||
</li>
|
||||
<li>
|
||||
<%= link nav_link_opts(@conn, to: Routes.user_session_path(@conn, :delete), method: :delete, class: "dropdown-item") do %>
|
||||
<%= icon_div @conn, "bi-box-arrow-right", [class: "icon baseline me-1"] %>
|
||||
Log out
|
||||
<% end %>
|
||||
</li>
|
||||
|
||||
<% else %>
|
||||
|
||||
<li>
|
||||
<%= link nav_link_opts(@conn, to: Routes.user_registration_path(@conn, :new), class: "dropdown-item") do %>
|
||||
<%= icon_div @conn, "bi-person-plus", [class: "icon baseline me-1"] %>
|
||||
Register
|
||||
<% end %>
|
||||
</li>
|
||||
<li>
|
||||
<%= link nav_link_opts(@conn, to: Routes.user_session_path(@conn, :new), class: "dropdown-item") do %>
|
||||
<%= icon_div @conn, "bi-box-arrow-in-left", [class: "icon baseline me-1"] %>
|
||||
Log in
|
||||
<% end %>
|
||||
</li>
|
||||
|
||||
<% end %>
|
||||
</ul>
|
||||
|
||||
</li>
|
19
lib/shift73k_web/templates/layout/root.html.leex
Normal file
19
lib/shift73k_web/templates/layout/root.html.leex
Normal file
|
@ -0,0 +1,19 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<%= csrf_meta_tag() %>
|
||||
<%= live_title_tag assigns[:page_title] || "Shift73k", suffix: " · Phoenix Framework" %>
|
||||
<link phx-track-static rel="stylesheet" href="<%= Routes.static_path(@conn, "/css/app.css") %>"/>
|
||||
<script defer phx-track-static type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<%= render "_navbar.html", assigns %>
|
||||
|
||||
<%= @inner_content %>
|
||||
|
||||
</body>
|
||||
</html>
|
41
lib/shift73k_web/templates/other/index.html.eex
Normal file
41
lib/shift73k_web/templates/other/index.html.eex
Normal file
|
@ -0,0 +1,41 @@
|
|||
<h1 class="text-3xl font-bold leading-tight text-gunmetal-200">
|
||||
Other Page
|
||||
</h1>
|
||||
<h2 class="text-xl leading-tight text-gunmetal-400">
|
||||
With a subtitle no less!
|
||||
</h2>
|
||||
|
||||
<div class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
<!-- Replace with your content -->
|
||||
<%# <div class="px-4 py-6 sm:px-0">
|
||||
<div class="border-4 border-dashed border-gray-200 rounded-lg h-96"></div>
|
||||
</div> %>
|
||||
<p>
|
||||
Praesent velit justo, auctor ut nibh id, fermentum eleifend nunc. Cras sed purus dignissim, ornare elit et, ultrices elit. Sed quis neque consequat, laoreet ante a, hendrerit elit. Maecenas dapibus sed nulla vitae consectetur. Duis sollicitudin augue nisl, et rhoncus enim tempor at. Fusce scelerisque sollicitudin purus sit amet iaculis. Phasellus lacinia mi ut laoreet accumsan. Sed sagittis erat nec sem placerat, ut volutpat neque porttitor. Suspendisse tempor mauris vel mollis sagittis. In ut laoreet arcu. Duis sed felis in dui imperdiet luctus nec faucibus sem. Donec commodo urna ut enim fringilla, quis lacinia ligula malesuada. Quisque feugiat fermentum pretium. Integer sed porttitor lacus, sed bibendum diam. Aliquam dapibus neque et pharetra interdum.
|
||||
</p>
|
||||
<!-- /End replace -->
|
||||
</div>
|
||||
|
||||
<!-- Button trigger modal -->
|
||||
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#exampleModal">
|
||||
Launch demo modal
|
||||
</button>
|
||||
|
||||
<!-- Modal -->
|
||||
<div class="modal fade" id="exampleModal" tabindex="-1" aria-labelledby="exampleModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="exampleModalLabel">Modal title</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
Aliquam ultrices elit purus, eget dignissim orci pulvinar id. Curabitur tincidunt, ligula eu condimentum porttitor, nibh sapien scelerisque urna, nec cursus nisi nisi a neque. Mauris hendrerit orci blandit, suscipit ante nec, porttitor neque. Nunc.
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<button type="button" class="btn btn-primary">Save changes</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
40
lib/shift73k_web/templates/user_confirmation/new.html.eex
Normal file
40
lib/shift73k_web/templates/user_confirmation/new.html.eex
Normal file
|
@ -0,0 +1,40 @@
|
|||
<div class="row justify-content-center">
|
||||
<div class="col-sm-9 col-md-7 col-lg-5 col-xl-4 ">
|
||||
|
||||
<h2>
|
||||
<%= icon_div @conn, "bi-arrow-repeat", [class: "icon baseline"] %>
|
||||
Resend confirmation instructions
|
||||
</h2>
|
||||
<p class="lead">We'll send you another email with instructions to confirm your email address.</p>
|
||||
|
||||
<%= form_for :user, Routes.user_confirmation_path(@conn, :create), [class: "needs-validation", novalidate: true], fn f -> %>
|
||||
|
||||
<%= 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"] %>
|
||||
</span>
|
||||
<%= email_input f, :email,
|
||||
value: @current_user && @current_user.email || "",
|
||||
placeholder: "e.g., babka@73k.us",
|
||||
class: "form-control",
|
||||
maxlength: User.max_email,
|
||||
required: true,
|
||||
autofocus: !@current_user
|
||||
%>
|
||||
<span class="invalid-feedback">must be a valid email address</span>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<%= submit "Resend confirmation instructions", class: "btn btn-primary" %>
|
||||
</div>
|
||||
|
||||
<% end %>
|
||||
|
||||
<p>
|
||||
<%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
|
||||
<%= link "Log in", to: Routes.user_session_path(@conn, :new) %>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</div>
|
38
lib/shift73k_web/templates/user_reset_password/new.html.eex
Normal file
38
lib/shift73k_web/templates/user_reset_password/new.html.eex
Normal file
|
@ -0,0 +1,38 @@
|
|||
<div class="row justify-content-center">
|
||||
<div class="col-sm-9 col-md-7 col-lg-5 col-xl-4 ">
|
||||
|
||||
<h2>
|
||||
<%= icon_div @conn, "mdi-head-question-outline", [class: "icon baseline"] %>
|
||||
Forgot your password?
|
||||
</h2>
|
||||
<p class="lead">We'll send you an email with instructions to reset your password.</p>
|
||||
|
||||
<%= form_for :user, Routes.user_reset_password_path(@conn, :create), [class: "needs-validation", novalidate: true], fn f -> %>
|
||||
|
||||
<%= 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"] %>
|
||||
</span>
|
||||
<%= email_input f, :email,
|
||||
placeholder: "e.g., babka@73k.us",
|
||||
class: "form-control",
|
||||
maxlength: User.max_email,
|
||||
required: true,
|
||||
autofocus: true
|
||||
%>
|
||||
<span class="invalid-feedback">must be a valid email address</span>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<%= submit "Send instructions to reset password", class: "btn btn-primary" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<p>
|
||||
<%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
|
||||
<%= link "Log in", to: Routes.user_session_path(@conn, :new) %>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</div>
|
61
lib/shift73k_web/templates/user_session/new.html.eex
Normal file
61
lib/shift73k_web/templates/user_session/new.html.eex
Normal file
|
@ -0,0 +1,61 @@
|
|||
<div class="row justify-content-center">
|
||||
<div class="col-sm-9 col-md-7 col-lg-5 col-xl-4 ">
|
||||
|
||||
<h2>
|
||||
<%= icon_div @conn, "bi-door-open", [class: "icon baseline"] %>
|
||||
Log in
|
||||
</h2>
|
||||
<p class="lead">Who goes there?</p>
|
||||
|
||||
<%= form_for @conn, Routes.user_session_path(@conn, :create), [as: :user, class: "needs-validation", novalidate: true], fn f -> %>
|
||||
<%= if @error_message do %>
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<%= @error_message %>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= 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"] %>
|
||||
</span>
|
||||
<%= email_input f, :email,
|
||||
class: "form-control",
|
||||
placeholder: "e.g., babka@73k.us",
|
||||
maxlength: User.max_email,
|
||||
required: true
|
||||
%>
|
||||
<span class="invalid-feedback">must be a valid email address</span>
|
||||
</div>
|
||||
|
||||
<%= 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"] %>
|
||||
</span>
|
||||
<%= password_input f, :password,
|
||||
class: "form-control",
|
||||
required: true
|
||||
%>
|
||||
<span class="invalid-feedback">password is required</span>
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3 no-valid-style">
|
||||
<%= checkbox f, :remember_me, class: "form-check-input" %>
|
||||
<%= label f, :remember_me, "Keep me logged in for 60 days", class: "form-check-label" %>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<%= submit "Log in", class: "btn btn-primary" %>
|
||||
</div>
|
||||
|
||||
<% end %>
|
||||
|
||||
<p>
|
||||
<%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
|
||||
<%= link "Forgot your password?", to: Routes.user_reset_password_path(@conn, :new) %>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</div>
|
72
lib/shift73k_web/views/error_helpers.ex
Normal file
72
lib/shift73k_web/views/error_helpers.ex
Normal file
|
@ -0,0 +1,72 @@
|
|||
defmodule Shift73kWeb.ErrorHelpers do
|
||||
@moduledoc """
|
||||
Conveniences for translating and building error messages.
|
||||
"""
|
||||
|
||||
use Phoenix.HTML
|
||||
|
||||
@doc """
|
||||
Generates tag for inlined form input errors.
|
||||
"""
|
||||
def error_tag(form, field, opts \\ []) do
|
||||
opts = error_opts(form, field, opts)
|
||||
|
||||
form.errors
|
||||
|> Keyword.get_values(field)
|
||||
|> Enum.map(fn error -> content_tag(:span, translate_error(error), opts) end)
|
||||
end
|
||||
|
||||
defp error_opts(form, field, opts) do
|
||||
append = "invalid-feedback"
|
||||
input_id = input_id(form, field)
|
||||
|
||||
opts
|
||||
|> Keyword.put_new(:id, error_id(input_id))
|
||||
|> Keyword.put_new(:phx_feedback_for, input_id)
|
||||
|> Keyword.update(:class, append, fn c -> "#{append} #{c}" end)
|
||||
end
|
||||
|
||||
def error_id(%Phoenix.HTML.Form{} = form, field), do: input_id(form, field) |> error_id()
|
||||
def error_id(input_id) when is_binary(input_id), do: "#{input_id}_feedback"
|
||||
|
||||
def input_class(form, field, classes \\ "") do
|
||||
case form.source.action do
|
||||
nil ->
|
||||
classes
|
||||
|
||||
_ ->
|
||||
case Keyword.has_key?(form.errors, field) do
|
||||
true -> "#{classes} is-invalid"
|
||||
_ -> "#{classes} is-valid"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Translates an error message using gettext.
|
||||
"""
|
||||
def translate_error({msg, opts}) do
|
||||
# When using gettext, we typically pass the strings we want
|
||||
# to translate as a static argument:
|
||||
#
|
||||
# # Translate "is invalid" in the "errors" domain
|
||||
# dgettext("errors", "is invalid")
|
||||
#
|
||||
# # Translate the number of files with plural rules
|
||||
# dngettext("errors", "1 file", "%{count} files", count)
|
||||
#
|
||||
# Because the error messages we show in our forms and APIs
|
||||
# are defined inside Ecto, we need to translate them dynamically.
|
||||
# This requires us to call the Gettext module passing our gettext
|
||||
# backend as first argument.
|
||||
#
|
||||
# Note we use the "errors" domain, which means translations
|
||||
# should be written to the errors.po file. The :count option is
|
||||
# set by Ecto and indicates we should also apply plural rules.
|
||||
if count = opts[:count] do
|
||||
Gettext.dngettext(Shift73kWeb.Gettext, "errors", msg, msg, count, opts)
|
||||
else
|
||||
Gettext.dgettext(Shift73kWeb.Gettext, "errors", msg, opts)
|
||||
end
|
||||
end
|
||||
end
|
16
lib/shift73k_web/views/error_view.ex
Normal file
16
lib/shift73k_web/views/error_view.ex
Normal file
|
@ -0,0 +1,16 @@
|
|||
defmodule Shift73kWeb.ErrorView do
|
||||
use Shift73kWeb, :view
|
||||
|
||||
# If you want to customize a particular status code
|
||||
# for a certain format, you may uncomment below.
|
||||
# def render("500.html", _assigns) do
|
||||
# "Internal Server Error"
|
||||
# end
|
||||
|
||||
# By default, Phoenix returns the status message from
|
||||
# the template name. For example, "404.html" becomes
|
||||
# "Not Found".
|
||||
def template_not_found(template, _assigns) do
|
||||
Phoenix.Controller.status_message_from_template(template)
|
||||
end
|
||||
end
|
35
lib/shift73k_web/views/icon_helpers.ex
Normal file
35
lib/shift73k_web/views/icon_helpers.ex
Normal file
|
@ -0,0 +1,35 @@
|
|||
defmodule Shift73kWeb.IconHelpers do
|
||||
@moduledoc """
|
||||
Generate SVG sprite use tags for SVG icons
|
||||
"""
|
||||
|
||||
use Phoenix.HTML
|
||||
alias Shift73kWeb.Router.Helpers, as: Routes
|
||||
|
||||
def icon_div(conn, name, div_opts \\ [], svg_opts \\ []) do
|
||||
content_tag(:div, tag_opts(name, div_opts)) do
|
||||
icon_svg(conn, name, svg_opts)
|
||||
end
|
||||
end
|
||||
|
||||
def icon_svg(conn, name, opts \\ []) do
|
||||
opts = aria_hidden?(opts)
|
||||
|
||||
content_tag(:svg, tag_opts(name, opts)) do
|
||||
tag(:use, "xlink:href": Routes.static_path(conn, "/images/icons.svg##{name}"))
|
||||
end
|
||||
end
|
||||
|
||||
defp tag_opts(name, opts) do
|
||||
Keyword.update(opts, :class, name, fn c -> "#{c} #{name}" end)
|
||||
end
|
||||
|
||||
defp aria_hidden?(opts) do
|
||||
case Keyword.get(opts, :aria_hidden) do
|
||||
"false" -> Keyword.drop(opts, [:aria_hidden])
|
||||
false -> Keyword.drop(opts, [:aria_hidden])
|
||||
"true" -> opts
|
||||
_ -> Keyword.put(opts, :aria_hidden, "true")
|
||||
end
|
||||
end
|
||||
end
|
24
lib/shift73k_web/views/layout_view.ex
Normal file
24
lib/shift73k_web/views/layout_view.ex
Normal file
|
@ -0,0 +1,24 @@
|
|||
defmodule Shift73kWeb.LayoutView do
|
||||
use Shift73kWeb, :view
|
||||
|
||||
alias Shift73k.Accounts.User
|
||||
alias Shift73kWeb.Roles
|
||||
|
||||
def nav_link_opts(conn, opts) do
|
||||
case Keyword.get(opts, :to) == Phoenix.Controller.current_path(conn) do
|
||||
false -> opts
|
||||
true -> Keyword.update(opts, :class, "active", fn c -> "#{c} active" end)
|
||||
end
|
||||
end
|
||||
|
||||
def alert_kinds do
|
||||
[
|
||||
success: "alert-success",
|
||||
info: "alert-info",
|
||||
error: "alert-danger",
|
||||
warning: "alert-warning",
|
||||
primary: "alert-primary",
|
||||
secondary: "alert-secondary"
|
||||
]
|
||||
end
|
||||
end
|
3
lib/shift73k_web/views/other_view.ex
Normal file
3
lib/shift73k_web/views/other_view.ex
Normal file
|
@ -0,0 +1,3 @@
|
|||
defmodule Shift73kWeb.OtherView do
|
||||
use Shift73kWeb, :view
|
||||
end
|
29
lib/shift73k_web/views/pagination.ex
Normal file
29
lib/shift73k_web/views/pagination.ex
Normal file
|
@ -0,0 +1,29 @@
|
|||
defmodule Shift73kWeb.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
|
4
lib/shift73k_web/views/user_confirmation_view.ex
Normal file
4
lib/shift73k_web/views/user_confirmation_view.ex
Normal file
|
@ -0,0 +1,4 @@
|
|||
defmodule Shift73kWeb.UserConfirmationView do
|
||||
use Shift73kWeb, :view
|
||||
alias Shift73k.Accounts.User
|
||||
end
|
4
lib/shift73k_web/views/user_reset_password_view.ex
Normal file
4
lib/shift73k_web/views/user_reset_password_view.ex
Normal file
|
@ -0,0 +1,4 @@
|
|||
defmodule Shift73kWeb.UserResetPasswordView do
|
||||
use Shift73kWeb, :view
|
||||
alias Shift73k.Accounts.User
|
||||
end
|
4
lib/shift73k_web/views/user_session_view.ex
Normal file
4
lib/shift73k_web/views/user_session_view.ex
Normal file
|
@ -0,0 +1,4 @@
|
|||
defmodule Shift73kWeb.UserSessionView do
|
||||
use Shift73kWeb, :view
|
||||
alias Shift73k.Accounts.User
|
||||
end
|
Loading…
Add table
Add a link
Reference in a new issue