refactored for new project name

This commit is contained in:
Adam Piontek 2021-03-05 19:23:32 -05:00
parent 0039146cd4
commit 82ab1d1ea5
113 changed files with 417 additions and 412 deletions

View 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

View 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

View 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

View 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

View file

@ -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

View file

@ -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

View 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} &mdash; 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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 &amp; 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>

View 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

View 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 %>

View 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

View 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>

View 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

View 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 %>

View 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 &mdash; registration failed for some reason.")
|> assign(:changeset, cs)
|> live_noreply()
end
end
end

View 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>

View 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

View 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 %> &mdash; 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>

View 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

View 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>

View 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

View 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>

View 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

View 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>

View 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

View 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

View file

@ -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>

View 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

View file

@ -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 %>

View 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

View 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">&hellip;</span>
<span class="visually-hidden" role="img" aria-label="ellipses">&hellip;</span>
</li>
<% page_num == @page.page_number -> %>
<li class="page-item active" aria-current="page">
<span class="page-link"><%= page_num %></a>
</li>
<% true -> %>
<li class="page-item">
<a class="page-link" href="#" phx-value-page_number="<%= page_num %>" phx-click="page-change"><%= page_num %></a>
</li>
<% end %>
<% end %>
<%# next page button %>
<% icon = icon_div @socket, "bi-chevron-right", [class: "icon baseline"] %>
<%= if @page.page_number == @page.total_pages do %>
<li class="page-item disabled">
<span class="page-link" aria-hidden="true"><%= icon %></span>
<span class="visually-hidden">Next</span>
<% else %>
<li class="page-item">
<a class="page-link" href="#" aria-label="Next" phx-value-page_number="<%= @page.page_number + 1 %>" phx-click="page-change"><%= icon %></a>
<% end %>
</li>
</ul>
</nav>
</div>
<% end %>

View 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
View 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
View 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

View 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

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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

View 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

View 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

View 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

View file

@ -0,0 +1,3 @@
defmodule Shift73kWeb.OtherView do
use Shift73kWeb, :view
end

View 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

View file

@ -0,0 +1,4 @@
defmodule Shift73kWeb.UserConfirmationView do
use Shift73kWeb, :view
alias Shift73k.Accounts.User
end

View file

@ -0,0 +1,4 @@
defmodule Shift73kWeb.UserResetPasswordView do
use Shift73kWeb, :view
alias Shift73k.Accounts.User
end

View file

@ -0,0 +1,4 @@
defmodule Shift73kWeb.UserSessionView do
use Shift73kWeb, :view
alias Shift73k.Accounts.User
end