forked from shibao/cannery
		
	rename to cannery
This commit is contained in:
		
							
								
								
									
										70
									
								
								lib/cannery_web/components/invite_card.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								lib/cannery_web/components/invite_card.ex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,70 @@
 | 
			
		||||
defmodule CanneryWeb.Components.InviteCard do
 | 
			
		||||
  @moduledoc """
 | 
			
		||||
  Display card for an invite
 | 
			
		||||
  """
 | 
			
		||||
 | 
			
		||||
  use CanneryWeb, :component
 | 
			
		||||
  alias Cannery.Accounts.{Invite, Invites, User}
 | 
			
		||||
  alias CanneryWeb.Endpoint
 | 
			
		||||
 | 
			
		||||
  attr :invite, Invite, required: true
 | 
			
		||||
  attr :current_user, User, required: true
 | 
			
		||||
  slot(:inner_block)
 | 
			
		||||
  slot(:code_actions)
 | 
			
		||||
 | 
			
		||||
  def invite_card(%{invite: invite, current_user: current_user} = assigns) do
 | 
			
		||||
    assigns =
 | 
			
		||||
      assigns
 | 
			
		||||
      |> assign(:use_count, Invites.get_use_count(invite, current_user))
 | 
			
		||||
      |> assign_new(:code_actions, fn -> [] end)
 | 
			
		||||
 | 
			
		||||
    ~H"""
 | 
			
		||||
    <div class="mx-4 my-2 px-8 py-4 flex flex-col justify-center items-center space-y-4
 | 
			
		||||
      border border-gray-400 rounded-lg shadow-lg hover:shadow-md
 | 
			
		||||
      transition-all duration-300 ease-in-out">
 | 
			
		||||
      <h1 class="title text-xl">
 | 
			
		||||
        <%= @invite.name %>
 | 
			
		||||
      </h1>
 | 
			
		||||
 | 
			
		||||
      <%= if @invite.disabled_at |> is_nil() do %>
 | 
			
		||||
        <h2 class="title text-md">
 | 
			
		||||
          <%= if @invite.uses_left do %>
 | 
			
		||||
            <%= gettext(
 | 
			
		||||
              "Uses Left: %{uses_left_count}",
 | 
			
		||||
              uses_left_count: @invite.uses_left
 | 
			
		||||
            ) %>
 | 
			
		||||
          <% else %>
 | 
			
		||||
            <%= gettext("Uses Left: Unlimited") %>
 | 
			
		||||
          <% end %>
 | 
			
		||||
        </h2>
 | 
			
		||||
      <% else %>
 | 
			
		||||
        <h2 class="title text-md">
 | 
			
		||||
          <%= gettext("Invite Disabled") %>
 | 
			
		||||
        </h2>
 | 
			
		||||
      <% end %>
 | 
			
		||||
 | 
			
		||||
      <.qr_code
 | 
			
		||||
        content={Routes.user_registration_url(Endpoint, :new, invite: @invite.token)}
 | 
			
		||||
        filename={@invite.name}
 | 
			
		||||
      />
 | 
			
		||||
 | 
			
		||||
      <h2 :if={@use_count != 0} class="title text-md">
 | 
			
		||||
        <%= gettext("Uses: %{uses_count}", uses_count: @use_count) %>
 | 
			
		||||
      </h2>
 | 
			
		||||
 | 
			
		||||
      <div class="flex flex-row flex-wrap justify-center items-center">
 | 
			
		||||
        <code
 | 
			
		||||
          id={"code-#{@invite.id}"}
 | 
			
		||||
          class="mx-2 my-1 text-xs px-4 py-2 rounded-lg text-center break-all text-gray-100 bg-primary-800"
 | 
			
		||||
          phx-no-format
 | 
			
		||||
        ><%= Routes.user_registration_url(Endpoint, :new, invite: @invite.token) %></code>
 | 
			
		||||
        <%= render_slot(@code_actions) %>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div :if={@inner_block} class="flex space-x-4 justify-center items-center">
 | 
			
		||||
        <%= render_slot(@inner_block) %>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    """
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										87
									
								
								lib/cannery_web/components/table_component.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								lib/cannery_web/components/table_component.ex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,87 @@
 | 
			
		||||
defmodule CanneryWeb.Components.TableComponent do
 | 
			
		||||
  @moduledoc """
 | 
			
		||||
  Livecomponent that presents a resortable table
 | 
			
		||||
 | 
			
		||||
  It takes the following required assigns:
 | 
			
		||||
    - `:columns`: An array of maps containing the following keys
 | 
			
		||||
      - `:label`: A gettext'd or otherwise user-facing string label for the
 | 
			
		||||
        column. Can be nil
 | 
			
		||||
      - `:key`: A string key used for sorting
 | 
			
		||||
      - `:class`: Extra classes to be applied to the column element, if desired.
 | 
			
		||||
        Optional
 | 
			
		||||
      - `:sortable`: If false, will prevent the user from sorting with it.
 | 
			
		||||
        Optional
 | 
			
		||||
    - `:values`: An array of maps containing data for each row. Each map is
 | 
			
		||||
      string-keyed with the associated column key to the following values:
 | 
			
		||||
      - A single element, like string, integer or Phoenix.LiveView.Rendered
 | 
			
		||||
        object, like returned from the ~H sigil
 | 
			
		||||
      - A tuple, containing a custom value used for sorting, and the displayed
 | 
			
		||||
        content.
 | 
			
		||||
  """
 | 
			
		||||
 | 
			
		||||
  use CanneryWeb, :live_component
 | 
			
		||||
  alias Phoenix.LiveView.Socket
 | 
			
		||||
 | 
			
		||||
  @impl true
 | 
			
		||||
  @spec update(
 | 
			
		||||
          %{
 | 
			
		||||
            required(:columns) =>
 | 
			
		||||
              list(%{
 | 
			
		||||
                required(:label) => String.t() | nil,
 | 
			
		||||
                required(:key) => String.t() | nil,
 | 
			
		||||
                optional(:class) => String.t(),
 | 
			
		||||
                optional(:sortable) => false
 | 
			
		||||
              }),
 | 
			
		||||
            required(:rows) =>
 | 
			
		||||
              list(%{
 | 
			
		||||
                (key :: String.t()) => any() | {custom_sort_value :: String.t(), value :: any()}
 | 
			
		||||
              }),
 | 
			
		||||
            optional(any()) => any()
 | 
			
		||||
          },
 | 
			
		||||
          Socket.t()
 | 
			
		||||
        ) :: {:ok, Socket.t()}
 | 
			
		||||
  def update(%{columns: columns, rows: rows} = assigns, socket) do
 | 
			
		||||
    initial_key = columns |> List.first() |> Map.get(:key)
 | 
			
		||||
    rows = rows |> Enum.sort_by(fn row -> row |> Map.get(initial_key) end, :asc)
 | 
			
		||||
 | 
			
		||||
    socket =
 | 
			
		||||
      socket
 | 
			
		||||
      |> assign(assigns)
 | 
			
		||||
      |> assign(columns: columns, rows: rows, last_sort_key: initial_key, sort_mode: :asc)
 | 
			
		||||
 | 
			
		||||
    {:ok, socket}
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  @impl true
 | 
			
		||||
  def handle_event(
 | 
			
		||||
        "sort_by",
 | 
			
		||||
        %{"sort-key" => key},
 | 
			
		||||
        %{assigns: %{rows: rows, last_sort_key: key, sort_mode: sort_mode}} = socket
 | 
			
		||||
      ) do
 | 
			
		||||
    sort_mode = if sort_mode == :asc, do: :desc, else: :asc
 | 
			
		||||
    rows = rows |> sort_by_custom_sort_value_or_value(key, sort_mode)
 | 
			
		||||
    {:noreply, socket |> assign(sort_mode: sort_mode, rows: rows)}
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def handle_event(
 | 
			
		||||
        "sort_by",
 | 
			
		||||
        %{"sort-key" => key},
 | 
			
		||||
        %{assigns: %{rows: rows}} = socket
 | 
			
		||||
      ) do
 | 
			
		||||
    rows = rows |> sort_by_custom_sort_value_or_value(key, :asc)
 | 
			
		||||
    {:noreply, socket |> assign(last_sort_key: key, sort_mode: :asc, rows: rows)}
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  defp sort_by_custom_sort_value_or_value(rows, key, sort_mode) do
 | 
			
		||||
    rows
 | 
			
		||||
    |> Enum.sort_by(
 | 
			
		||||
      fn row ->
 | 
			
		||||
        case row |> Map.get(key) do
 | 
			
		||||
          {custom_sort_key, _value} -> custom_sort_key
 | 
			
		||||
          value -> value
 | 
			
		||||
        end
 | 
			
		||||
      end,
 | 
			
		||||
      sort_mode
 | 
			
		||||
    )
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										48
									
								
								lib/cannery_web/components/table_component.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								lib/cannery_web/components/table_component.html.heex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,48 @@
 | 
			
		||||
<div class="w-full overflow-x-auto border border-gray-600 rounded-lg shadow-lg bg-black">
 | 
			
		||||
  <table class="min-w-full table-auto text-center bg-white">
 | 
			
		||||
    <thead class="border-b border-primary-600">
 | 
			
		||||
      <tr>
 | 
			
		||||
        <%= for %{key: key, label: label} = column <- @columns do %>
 | 
			
		||||
          <%= if column |> Map.get(:sortable, true) do %>
 | 
			
		||||
            <th class={["p-2", column[:class]]}>
 | 
			
		||||
              <span
 | 
			
		||||
                class="cursor-pointer"
 | 
			
		||||
                phx-click="sort_by"
 | 
			
		||||
                phx-value-sort-key={key}
 | 
			
		||||
                phx-target={@myself}
 | 
			
		||||
              >
 | 
			
		||||
                <span class="underline"><%= label %></span>
 | 
			
		||||
                <%= if @last_sort_key == key do %>
 | 
			
		||||
                  <%= case @sort_mode do %>
 | 
			
		||||
                    <% :asc -> %>
 | 
			
		||||
                      <i class="fas fa-sm fa-chevron-down"></i>
 | 
			
		||||
                    <% :desc -> %>
 | 
			
		||||
                      <i class="fas fa-sm fa-chevron-up"></i>
 | 
			
		||||
                  <% end %>
 | 
			
		||||
                <% else %>
 | 
			
		||||
                  <i class="fas fa-sm fa-chevron-up opacity-0"></i>
 | 
			
		||||
                <% end %>
 | 
			
		||||
              </span>
 | 
			
		||||
            </th>
 | 
			
		||||
          <% else %>
 | 
			
		||||
            <th class={["p-2", column[:class]]}>
 | 
			
		||||
              <%= label %>
 | 
			
		||||
            </th>
 | 
			
		||||
          <% end %>
 | 
			
		||||
        <% end %>
 | 
			
		||||
      </tr>
 | 
			
		||||
    </thead>
 | 
			
		||||
    <tbody>
 | 
			
		||||
      <tr :for={values <- @rows}>
 | 
			
		||||
        <td :for={%{key: key} = value <- @columns} class={["p-2", value[:class]]}>
 | 
			
		||||
          <%= case values |> Map.get(key) do %>
 | 
			
		||||
            <% {_custom_sort_value, value} -> %>
 | 
			
		||||
              <%= value %>
 | 
			
		||||
            <% value -> %>
 | 
			
		||||
              <%= value %>
 | 
			
		||||
          <% end %>
 | 
			
		||||
        </td>
 | 
			
		||||
      </tr>
 | 
			
		||||
    </tbody>
 | 
			
		||||
  </table>
 | 
			
		||||
</div>
 | 
			
		||||
							
								
								
									
										100
									
								
								lib/cannery_web/components/topbar.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										100
									
								
								lib/cannery_web/components/topbar.ex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,100 @@
 | 
			
		||||
defmodule CanneryWeb.Components.Topbar do
 | 
			
		||||
  @moduledoc """
 | 
			
		||||
  Component that renders a topbar with user functions/links
 | 
			
		||||
  """
 | 
			
		||||
 | 
			
		||||
  use CanneryWeb, :component
 | 
			
		||||
 | 
			
		||||
  alias Cannery.Accounts
 | 
			
		||||
  alias CanneryWeb.{Endpoint, HomeLive}
 | 
			
		||||
 | 
			
		||||
  def topbar(assigns) do
 | 
			
		||||
    assigns =
 | 
			
		||||
      %{results: [], title_content: nil, flash: nil, current_user: nil} |> Map.merge(assigns)
 | 
			
		||||
 | 
			
		||||
    ~H"""
 | 
			
		||||
    <nav role="navigation" class="mb-8 px-8 py-4 w-full bg-primary-400">
 | 
			
		||||
      <div class="flex flex-col sm:flex-row justify-between items-center">
 | 
			
		||||
        <div class="mb-4 sm:mb-0 sm:mr-8 flex flex-row justify-start items-center space-x-2">
 | 
			
		||||
          <.link
 | 
			
		||||
            navigate={Routes.live_path(Endpoint, HomeLive)}
 | 
			
		||||
            class="mx-2 my-1 leading-5 text-xl text-white hover:underline"
 | 
			
		||||
          >
 | 
			
		||||
            <%= gettext("Cannery") %>
 | 
			
		||||
          </.link>
 | 
			
		||||
 | 
			
		||||
          <%= if @title_content do %>
 | 
			
		||||
            <span class="mx-2 my-1">
 | 
			
		||||
              |
 | 
			
		||||
            </span>
 | 
			
		||||
            <%= @title_content %>
 | 
			
		||||
          <% end %>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <hr class="mb-2 sm:hidden hr-light" />
 | 
			
		||||
 | 
			
		||||
        <ul class="flex flex-row flex-wrap justify-center items-center
 | 
			
		||||
          text-lg text-white text-ellipsis">
 | 
			
		||||
          <%= if @current_user do %>
 | 
			
		||||
            <li :if={@current_user.role == :admin} class="mx-2 my-1">
 | 
			
		||||
              <.link
 | 
			
		||||
                navigate={Routes.invite_index_path(Endpoint, :index)}
 | 
			
		||||
                class="text-white text-white hover:underline"
 | 
			
		||||
              >
 | 
			
		||||
                <%= gettext("Invites") %>
 | 
			
		||||
              </.link>
 | 
			
		||||
            </li>
 | 
			
		||||
            <li class="mx-2 my-1">
 | 
			
		||||
              <.link
 | 
			
		||||
                navigate={Routes.user_settings_path(Endpoint, :edit)}
 | 
			
		||||
                class="text-white text-white hover:underline truncate"
 | 
			
		||||
              >
 | 
			
		||||
                <%= @current_user.email %>
 | 
			
		||||
              </.link>
 | 
			
		||||
            </li>
 | 
			
		||||
            <li class="mx-2 my-1">
 | 
			
		||||
              <.link
 | 
			
		||||
                href={Routes.user_session_path(Endpoint, :delete)}
 | 
			
		||||
                method="delete"
 | 
			
		||||
                data-confirm={dgettext("prompts", "Are you sure you want to log out?")}
 | 
			
		||||
              >
 | 
			
		||||
                <i class="fas fa-sign-out-alt"></i>
 | 
			
		||||
              </.link>
 | 
			
		||||
            </li>
 | 
			
		||||
            <li
 | 
			
		||||
              :if={
 | 
			
		||||
                @current_user.role == :admin and function_exported?(Routes, :live_dashboard_path, 2)
 | 
			
		||||
              }
 | 
			
		||||
              class="mx-2 my-1"
 | 
			
		||||
            >
 | 
			
		||||
              <.link
 | 
			
		||||
                navigate={Routes.live_dashboard_path(Endpoint, :home)}
 | 
			
		||||
                class="text-white text-white hover:underline"
 | 
			
		||||
              >
 | 
			
		||||
                <i class="fas fa-gauge"></i>
 | 
			
		||||
              </.link>
 | 
			
		||||
            </li>
 | 
			
		||||
          <% else %>
 | 
			
		||||
            <li :if={Accounts.allow_registration?()} class="mx-2 my-1">
 | 
			
		||||
              <.link
 | 
			
		||||
                navigate={Routes.user_registration_path(Endpoint, :new)}
 | 
			
		||||
                class="text-white text-white hover:underline truncate"
 | 
			
		||||
              >
 | 
			
		||||
                <%= dgettext("actions", "Register") %>
 | 
			
		||||
              </.link>
 | 
			
		||||
            </li>
 | 
			
		||||
            <li class="mx-2 my-1">
 | 
			
		||||
              <.link
 | 
			
		||||
                navigate={Routes.user_session_path(Endpoint, :new)}
 | 
			
		||||
                class="text-white text-white hover:underline truncate"
 | 
			
		||||
              >
 | 
			
		||||
                <%= dgettext("actions", "Log in") %>
 | 
			
		||||
              </.link>
 | 
			
		||||
            </li>
 | 
			
		||||
          <% end %>
 | 
			
		||||
        </ul>
 | 
			
		||||
      </div>
 | 
			
		||||
    </nav>
 | 
			
		||||
    """
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										48
									
								
								lib/cannery_web/components/user_card.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								lib/cannery_web/components/user_card.ex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,48 @@
 | 
			
		||||
defmodule CanneryWeb.Components.UserCard do
 | 
			
		||||
  @moduledoc """
 | 
			
		||||
  Display card for a user
 | 
			
		||||
  """
 | 
			
		||||
 | 
			
		||||
  use CanneryWeb, :component
 | 
			
		||||
 | 
			
		||||
  def user_card(assigns) do
 | 
			
		||||
    ~H"""
 | 
			
		||||
    <div
 | 
			
		||||
      id={"user-#{@user.id}"}
 | 
			
		||||
      class="mx-4 my-2 px-8 py-4 flex flex-col justify-center items-center text-center
 | 
			
		||||
        border border-gray-400 rounded-lg shadow-lg hover:shadow-md
 | 
			
		||||
        transition-all duration-300 ease-in-out"
 | 
			
		||||
    >
 | 
			
		||||
      <h1 class="px-4 py-2 rounded-lg title text-xl break-all">
 | 
			
		||||
        <%= @user.email %>
 | 
			
		||||
      </h1>
 | 
			
		||||
 | 
			
		||||
      <h3 class="px-4 py-2 rounded-lg title text-lg">
 | 
			
		||||
        <p>
 | 
			
		||||
          <%= if @user.confirmed_at do %>
 | 
			
		||||
            <%= gettext(
 | 
			
		||||
              "User was confirmed at%{confirmed_datetime}",
 | 
			
		||||
              confirmed_datetime: ""
 | 
			
		||||
            ) %>
 | 
			
		||||
            <.datetime datetime={@user.confirmed_at} />
 | 
			
		||||
          <% else %>
 | 
			
		||||
            <%= gettext("Email unconfirmed") %>
 | 
			
		||||
          <% end %>
 | 
			
		||||
        </p>
 | 
			
		||||
 | 
			
		||||
        <p>
 | 
			
		||||
          <%= gettext(
 | 
			
		||||
            "User registered on%{registered_datetime}",
 | 
			
		||||
            registered_datetime: ""
 | 
			
		||||
          ) %>
 | 
			
		||||
          <.datetime datetime={@user.inserted_at} />
 | 
			
		||||
        </p>
 | 
			
		||||
      </h3>
 | 
			
		||||
 | 
			
		||||
      <div :if={@inner_block} class="px-4 py-2 flex space-x-4 justify-center items-center">
 | 
			
		||||
        <%= render_slot(@inner_block) %>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    """
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										23
									
								
								lib/cannery_web/controllers/email_controller.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								lib/cannery_web/controllers/email_controller.ex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,23 @@
 | 
			
		||||
defmodule CanneryWeb.EmailController do
 | 
			
		||||
  @moduledoc """
 | 
			
		||||
  A dev controller used to develop on emails
 | 
			
		||||
  """
 | 
			
		||||
 | 
			
		||||
  use CanneryWeb, :controller
 | 
			
		||||
  alias Cannery.Accounts.User
 | 
			
		||||
 | 
			
		||||
  plug :put_layout, {CanneryWeb.LayoutView, :email}
 | 
			
		||||
 | 
			
		||||
  @sample_assigns %{
 | 
			
		||||
    email: %{subject: "Example subject"},
 | 
			
		||||
    url: "https://cannery.bubbletea.dev/sample_url",
 | 
			
		||||
    user: %User{email: "sample@email.com"}
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @doc """
 | 
			
		||||
  Debug route used to preview emails
 | 
			
		||||
  """
 | 
			
		||||
  def preview(conn, %{"id" => template}) do
 | 
			
		||||
    render(conn, "#{template |> to_string()}.html", @sample_assigns)
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										11
									
								
								lib/cannery_web/controllers/home_controller.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								lib/cannery_web/controllers/home_controller.ex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,11 @@
 | 
			
		||||
defmodule CanneryWeb.HomeController do
 | 
			
		||||
  @moduledoc """
 | 
			
		||||
  Controller for home page
 | 
			
		||||
  """
 | 
			
		||||
 | 
			
		||||
  use CanneryWeb, :controller
 | 
			
		||||
 | 
			
		||||
  def index(conn, _params) do
 | 
			
		||||
    render(conn, "index.html")
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										191
									
								
								lib/cannery_web/controllers/user_auth.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										191
									
								
								lib/cannery_web/controllers/user_auth.ex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,191 @@
 | 
			
		||||
defmodule CanneryWeb.UserAuth do
 | 
			
		||||
  @moduledoc """
 | 
			
		||||
  Functions for user session and authentication
 | 
			
		||||
  """
 | 
			
		||||
 | 
			
		||||
  import Plug.Conn
 | 
			
		||||
  import Phoenix.Controller
 | 
			
		||||
  import CanneryWeb.Gettext
 | 
			
		||||
  alias Cannery.{Accounts, Accounts.User}
 | 
			
		||||
  alias CanneryWeb.HomeLive
 | 
			
		||||
  alias CanneryWeb.Router.Helpers, as: Routes
 | 
			
		||||
 | 
			
		||||
  # 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 "_cannery_web_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 \\ %{})
 | 
			
		||||
 | 
			
		||||
  def log_in_user(conn, %User{confirmed_at: nil}, _params) do
 | 
			
		||||
    conn
 | 
			
		||||
    |> fetch_flash()
 | 
			
		||||
    |> put_flash(
 | 
			
		||||
      :error,
 | 
			
		||||
      dgettext("errors", "You must confirm your account and log in to access this page.")
 | 
			
		||||
    )
 | 
			
		||||
    |> maybe_store_return_to()
 | 
			
		||||
    |> redirect(to: Routes.user_session_path(conn, :new))
 | 
			
		||||
    |> halt()
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def log_in_user(conn, user, params) do
 | 
			
		||||
    token = Accounts.generate_user_session_token(user)
 | 
			
		||||
    user_return_to = get_session(conn, :user_return_to)
 | 
			
		||||
 | 
			
		||||
    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: user_return_to || signed_in_path(conn))
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  @spec maybe_write_remember_me_cookie(
 | 
			
		||||
          Plug.Conn.t(),
 | 
			
		||||
          String.t() | any(),
 | 
			
		||||
          %{required(String.t()) => String.t()} | any()
 | 
			
		||||
        ) :: Plug.Conn.t()
 | 
			
		||||
  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
 | 
			
		||||
      CanneryWeb.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,
 | 
			
		||||
        dgettext("errors", "You must confirm your account and 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 to be an admin.
 | 
			
		||||
  """
 | 
			
		||||
  def require_role(conn, role: role_atom) do
 | 
			
		||||
    if conn.assigns[:current_user] && conn.assigns.current_user.role == role_atom do
 | 
			
		||||
      conn
 | 
			
		||||
    else
 | 
			
		||||
      conn
 | 
			
		||||
      |> put_flash(:error, dgettext("errors", "You are not authorized to view this page."))
 | 
			
		||||
      |> maybe_store_return_to()
 | 
			
		||||
      |> redirect(to: Routes.live_path(conn, HomeLive))
 | 
			
		||||
      |> halt()
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  defp maybe_store_return_to(%{method: "GET"} = conn) do
 | 
			
		||||
    put_session(conn, :user_return_to, current_path(conn))
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  defp maybe_store_return_to(conn), do: conn
 | 
			
		||||
 | 
			
		||||
  defp signed_in_path(_conn), do: "/"
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										60
									
								
								lib/cannery_web/controllers/user_confirmation_controller.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								lib/cannery_web/controllers/user_confirmation_controller.ex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,60 @@
 | 
			
		||||
defmodule CanneryWeb.UserConfirmationController do
 | 
			
		||||
  use CanneryWeb, :controller
 | 
			
		||||
 | 
			
		||||
  import CanneryWeb.Gettext
 | 
			
		||||
  alias Cannery.Accounts
 | 
			
		||||
 | 
			
		||||
  def new(conn, _params) do
 | 
			
		||||
    render(conn, "new.html", page_title: gettext("Confirm your account"))
 | 
			
		||||
  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,
 | 
			
		||||
      dgettext(
 | 
			
		||||
        "prompts",
 | 
			
		||||
        "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, %{email: email}} ->
 | 
			
		||||
        conn
 | 
			
		||||
        |> put_flash(:info, dgettext("prompts", "%{email} confirmed successfully.", email: email))
 | 
			
		||||
        |> redirect(to: "/")
 | 
			
		||||
 | 
			
		||||
      :error ->
 | 
			
		||||
        # If there is a current user and the account was already confirmed,
 | 
			
		||||
        # then odds are that the confirmation link was already visited, either
 | 
			
		||||
        # by some automation or by the user themselves, so we redirect without
 | 
			
		||||
        # a warning message.
 | 
			
		||||
        case conn.assigns do
 | 
			
		||||
          %{current_user: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) ->
 | 
			
		||||
            redirect(conn, to: "/")
 | 
			
		||||
 | 
			
		||||
          %{} ->
 | 
			
		||||
            conn
 | 
			
		||||
            |> put_flash(
 | 
			
		||||
              :error,
 | 
			
		||||
              dgettext("errors", "User confirmation link is invalid or it has expired.")
 | 
			
		||||
            )
 | 
			
		||||
            |> redirect(to: "/")
 | 
			
		||||
        end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										77
									
								
								lib/cannery_web/controllers/user_registration_controller.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										77
									
								
								lib/cannery_web/controllers/user_registration_controller.ex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,77 @@
 | 
			
		||||
defmodule CanneryWeb.UserRegistrationController do
 | 
			
		||||
  use CanneryWeb, :controller
 | 
			
		||||
  import CanneryWeb.Gettext
 | 
			
		||||
  alias Cannery.{Accounts, Accounts.Invites}
 | 
			
		||||
  alias CanneryWeb.{Endpoint, HomeLive}
 | 
			
		||||
 | 
			
		||||
  def new(conn, %{"invite" => invite_token}) do
 | 
			
		||||
    if Invites.valid_invite_token?(invite_token) do
 | 
			
		||||
      conn |> render_new(invite_token)
 | 
			
		||||
    else
 | 
			
		||||
      conn
 | 
			
		||||
      |> put_flash(:error, dgettext("errors", "Sorry, this invite was not found or expired"))
 | 
			
		||||
      |> redirect(to: Routes.live_path(Endpoint, HomeLive))
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def new(conn, _params) do
 | 
			
		||||
    if Accounts.allow_registration?() do
 | 
			
		||||
      conn |> render_new()
 | 
			
		||||
    else
 | 
			
		||||
      conn
 | 
			
		||||
      |> put_flash(:error, dgettext("errors", "Sorry, public registration is disabled"))
 | 
			
		||||
      |> redirect(to: Routes.live_path(Endpoint, HomeLive))
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # renders new user registration page
 | 
			
		||||
  defp render_new(conn, invite_token \\ nil) do
 | 
			
		||||
    render(conn, "new.html",
 | 
			
		||||
      changeset: Accounts.change_user_registration(),
 | 
			
		||||
      invite_token: invite_token,
 | 
			
		||||
      page_title: gettext("Register")
 | 
			
		||||
    )
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def create(conn, %{"user" => %{"invite_token" => invite_token}} = attrs) do
 | 
			
		||||
    if Invites.valid_invite_token?(invite_token) do
 | 
			
		||||
      conn |> create_user(attrs, invite_token)
 | 
			
		||||
    else
 | 
			
		||||
      conn
 | 
			
		||||
      |> put_flash(:error, dgettext("errors", "Sorry, this invite was not found or expired"))
 | 
			
		||||
      |> redirect(to: Routes.live_path(Endpoint, HomeLive))
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def create(conn, attrs) do
 | 
			
		||||
    if Accounts.allow_registration?() do
 | 
			
		||||
      conn |> create_user(attrs)
 | 
			
		||||
    else
 | 
			
		||||
      conn
 | 
			
		||||
      |> put_flash(:error, dgettext("errors", "Sorry, public registration is disabled"))
 | 
			
		||||
      |> redirect(to: Routes.live_path(Endpoint, HomeLive))
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  defp create_user(conn, %{"user" => user_params}, invite_token \\ nil) do
 | 
			
		||||
    case Accounts.register_user(user_params, invite_token) do
 | 
			
		||||
      {:ok, user} ->
 | 
			
		||||
        Accounts.deliver_user_confirmation_instructions(
 | 
			
		||||
          user,
 | 
			
		||||
          &Routes.user_confirmation_url(conn, :confirm, &1)
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        conn
 | 
			
		||||
        |> put_flash(:info, dgettext("prompts", "Please check your email to verify your account"))
 | 
			
		||||
        |> redirect(to: Routes.user_session_path(Endpoint, :new))
 | 
			
		||||
 | 
			
		||||
      {:error, :invalid_token} ->
 | 
			
		||||
        conn
 | 
			
		||||
        |> put_flash(:error, dgettext("errors", "Sorry, this invite was not found or expired"))
 | 
			
		||||
        |> redirect(to: Routes.live_path(Endpoint, HomeLive))
 | 
			
		||||
 | 
			
		||||
      {:error, %Ecto.Changeset{} = changeset} ->
 | 
			
		||||
        conn |> render("new.html", changeset: changeset, invite_token: invite_token)
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
@@ -0,0 +1,69 @@
 | 
			
		||||
defmodule CanneryWeb.UserResetPasswordController do
 | 
			
		||||
  use CanneryWeb, :controller
 | 
			
		||||
 | 
			
		||||
  alias Cannery.Accounts
 | 
			
		||||
 | 
			
		||||
  plug :get_user_by_reset_password_token when action in [:edit, :update]
 | 
			
		||||
 | 
			
		||||
  def new(conn, _params) do
 | 
			
		||||
    render(conn, "new.html", page_title: gettext("Forgot your password?"))
 | 
			
		||||
  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,
 | 
			
		||||
      dgettext(
 | 
			
		||||
        "prompts",
 | 
			
		||||
        "If your email is in our system, you will receive instructions to " <>
 | 
			
		||||
          "reset your password shortly."
 | 
			
		||||
      )
 | 
			
		||||
    )
 | 
			
		||||
    |> redirect(to: "/")
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def edit(conn, _params) do
 | 
			
		||||
    render(conn, "edit.html",
 | 
			
		||||
      changeset: Accounts.change_user_password(conn.assigns.user),
 | 
			
		||||
      page_title: gettext("Reset your password")
 | 
			
		||||
    )
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # Do not log in the user after reset password to avoid a
 | 
			
		||||
  # leaked token giving the user access to the account.
 | 
			
		||||
  def update(conn, %{"user" => user_params}) do
 | 
			
		||||
    case Accounts.reset_user_password(conn.assigns.user, user_params) do
 | 
			
		||||
      {:ok, _} ->
 | 
			
		||||
        conn
 | 
			
		||||
        |> put_flash(:info, dgettext("prompts", "Password reset successfully."))
 | 
			
		||||
        |> redirect(to: Routes.user_session_path(conn, :new))
 | 
			
		||||
 | 
			
		||||
      {:error, changeset} ->
 | 
			
		||||
        render(conn, "edit.html", changeset: changeset)
 | 
			
		||||
    end
 | 
			
		||||
  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
 | 
			
		||||
      conn |> assign(:user, user) |> assign(:token, token)
 | 
			
		||||
    else
 | 
			
		||||
      conn
 | 
			
		||||
      |> put_flash(
 | 
			
		||||
        :error,
 | 
			
		||||
        dgettext("errors", "Reset password link is invalid or it has expired.")
 | 
			
		||||
      )
 | 
			
		||||
      |> redirect(to: "/")
 | 
			
		||||
      |> halt()
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										26
									
								
								lib/cannery_web/controllers/user_session_controller.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								lib/cannery_web/controllers/user_session_controller.ex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,26 @@
 | 
			
		||||
defmodule CanneryWeb.UserSessionController do
 | 
			
		||||
  use CanneryWeb, :controller
 | 
			
		||||
 | 
			
		||||
  alias Cannery.Accounts
 | 
			
		||||
  alias CanneryWeb.UserAuth
 | 
			
		||||
 | 
			
		||||
  def new(conn, _params) do
 | 
			
		||||
    render(conn, "new.html", error_message: nil, page_title: gettext("Log in"))
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def create(conn, %{"user" => user_params}) do
 | 
			
		||||
    %{"email" => email, "password" => password} = user_params
 | 
			
		||||
 | 
			
		||||
    if user = Accounts.get_user_by_email_and_password(email, password) do
 | 
			
		||||
      UserAuth.log_in_user(conn, user, user_params)
 | 
			
		||||
    else
 | 
			
		||||
      render(conn, "new.html", error_message: dgettext("errors", "Invalid email or password"))
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def delete(conn, _params) do
 | 
			
		||||
    conn
 | 
			
		||||
    |> put_flash(:info, dgettext("prompts", "Logged out successfully."))
 | 
			
		||||
    |> UserAuth.log_out_user()
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										110
									
								
								lib/cannery_web/controllers/user_settings_controller.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										110
									
								
								lib/cannery_web/controllers/user_settings_controller.ex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,110 @@
 | 
			
		||||
defmodule CanneryWeb.UserSettingsController do
 | 
			
		||||
  use CanneryWeb, :controller
 | 
			
		||||
  import CanneryWeb.Gettext
 | 
			
		||||
  alias Cannery.Accounts
 | 
			
		||||
  alias CanneryWeb.{HomeLive, UserAuth}
 | 
			
		||||
 | 
			
		||||
  plug :assign_email_and_password_changesets
 | 
			
		||||
 | 
			
		||||
  def edit(conn, _params) do
 | 
			
		||||
    render(conn, "edit.html", page_title: gettext("Settings"))
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def update(%{assigns: %{current_user: user}} = conn, %{
 | 
			
		||||
        "action" => "update_email",
 | 
			
		||||
        "current_password" => password,
 | 
			
		||||
        "user" => user_params
 | 
			
		||||
      }) do
 | 
			
		||||
    case Accounts.apply_user_email(user, password, user_params) do
 | 
			
		||||
      {:ok, applied_user} ->
 | 
			
		||||
        Accounts.deliver_update_email_instructions(
 | 
			
		||||
          applied_user,
 | 
			
		||||
          user.email,
 | 
			
		||||
          &Routes.user_settings_url(conn, :confirm_email, &1)
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        conn
 | 
			
		||||
        |> put_flash(
 | 
			
		||||
          :info,
 | 
			
		||||
          dgettext(
 | 
			
		||||
            "prompts",
 | 
			
		||||
            "A link to confirm your email change has been sent to the new address."
 | 
			
		||||
          )
 | 
			
		||||
        )
 | 
			
		||||
        |> redirect(to: Routes.user_settings_path(conn, :edit))
 | 
			
		||||
 | 
			
		||||
      {:error, changeset} ->
 | 
			
		||||
        conn |> render("edit.html", email_changeset: changeset)
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def update(%{assigns: %{current_user: user}} = conn, %{
 | 
			
		||||
        "action" => "update_password",
 | 
			
		||||
        "current_password" => password,
 | 
			
		||||
        "user" => user_params
 | 
			
		||||
      }) do
 | 
			
		||||
    case Accounts.update_user_password(user, password, user_params) do
 | 
			
		||||
      {:ok, user} ->
 | 
			
		||||
        conn
 | 
			
		||||
        |> put_flash(:info, dgettext("prompts", "Password updated successfully."))
 | 
			
		||||
        |> put_session(:user_return_to, Routes.user_settings_path(conn, :edit))
 | 
			
		||||
        |> UserAuth.log_in_user(user)
 | 
			
		||||
 | 
			
		||||
      {:error, changeset} ->
 | 
			
		||||
        conn |> render("edit.html", password_changeset: changeset)
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def update(
 | 
			
		||||
        %{assigns: %{current_user: user}} = conn,
 | 
			
		||||
        %{"action" => "update_locale", "user" => %{"locale" => locale}}
 | 
			
		||||
      ) do
 | 
			
		||||
    case Accounts.update_user_locale(user, locale) do
 | 
			
		||||
      {:ok, _user} ->
 | 
			
		||||
        conn
 | 
			
		||||
        |> put_flash(:info, dgettext("prompts", "Language updated successfully."))
 | 
			
		||||
        |> redirect(to: Routes.user_settings_path(conn, :edit))
 | 
			
		||||
 | 
			
		||||
      {:error, changeset} ->
 | 
			
		||||
        conn |> render("edit.html", locale_changeset: changeset)
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def confirm_email(%{assigns: %{current_user: user}} = conn, %{"token" => token}) do
 | 
			
		||||
    case Accounts.update_user_email(user, token) do
 | 
			
		||||
      :ok ->
 | 
			
		||||
        conn
 | 
			
		||||
        |> put_flash(:info, dgettext("prompts", "Email changed successfully."))
 | 
			
		||||
        |> redirect(to: Routes.user_settings_path(conn, :edit))
 | 
			
		||||
 | 
			
		||||
      :error ->
 | 
			
		||||
        conn
 | 
			
		||||
        |> put_flash(
 | 
			
		||||
          :error,
 | 
			
		||||
          dgettext("errors", "Email change link is invalid or it has expired.")
 | 
			
		||||
        )
 | 
			
		||||
        |> redirect(to: Routes.user_settings_path(conn, :edit))
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def delete(%{assigns: %{current_user: current_user}} = conn, %{"id" => user_id}) do
 | 
			
		||||
    if user_id == current_user.id do
 | 
			
		||||
      current_user |> Accounts.delete_user!(current_user)
 | 
			
		||||
 | 
			
		||||
      conn
 | 
			
		||||
      |> put_flash(:error, dgettext("prompts", "Your account has been deleted"))
 | 
			
		||||
      |> redirect(to: Routes.live_path(conn, HomeLive))
 | 
			
		||||
    else
 | 
			
		||||
      conn
 | 
			
		||||
      |> put_flash(:error, dgettext("errors", "Unable to delete user"))
 | 
			
		||||
      |> redirect(to: Routes.user_settings_path(conn, :edit))
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  defp assign_email_and_password_changesets(%{assigns: %{current_user: user}} = conn, _opts) do
 | 
			
		||||
    conn
 | 
			
		||||
    |> assign(:email_changeset, Accounts.change_user_email(user))
 | 
			
		||||
    |> assign(:password_changeset, Accounts.change_user_password(user))
 | 
			
		||||
    |> assign(:locale_changeset, Accounts.change_user_locale(user))
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										50
									
								
								lib/cannery_web/endpoint.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								lib/cannery_web/endpoint.ex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,50 @@
 | 
			
		||||
defmodule CanneryWeb.Endpoint do
 | 
			
		||||
  use Phoenix.Endpoint, otp_app: :cannery
 | 
			
		||||
 | 
			
		||||
  # 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: "_cannery_key",
 | 
			
		||||
    signing_salt: "fxAnJltS"
 | 
			
		||||
  ]
 | 
			
		||||
 | 
			
		||||
  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: :cannery,
 | 
			
		||||
    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: :cannery
 | 
			
		||||
  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 CanneryWeb.Router
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										24
									
								
								lib/cannery_web/gettext.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								lib/cannery_web/gettext.ex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,24 @@
 | 
			
		||||
defmodule CanneryWeb.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 CanneryWeb.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: :cannery
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										18
									
								
								lib/cannery_web/live/home_live.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								lib/cannery_web/live/home_live.ex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,18 @@
 | 
			
		||||
defmodule CanneryWeb.HomeLive do
 | 
			
		||||
  @moduledoc """
 | 
			
		||||
  Liveview for the home page
 | 
			
		||||
  """
 | 
			
		||||
 | 
			
		||||
  use CanneryWeb, :live_view
 | 
			
		||||
  alias Cannery.Accounts
 | 
			
		||||
  alias CanneryWeb.Endpoint
 | 
			
		||||
 | 
			
		||||
  @version Mix.Project.config()[:version]
 | 
			
		||||
 | 
			
		||||
  @impl true
 | 
			
		||||
  def mount(_params, _session, socket) do
 | 
			
		||||
    admins = Accounts.list_users_by_role(:admin)
 | 
			
		||||
    socket = socket |> assign(page_title: gettext("Home"), admins: admins, version: @version)
 | 
			
		||||
    {:ok, socket}
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										135
									
								
								lib/cannery_web/live/home_live.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										135
									
								
								lib/cannery_web/live/home_live.html.heex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,135 @@
 | 
			
		||||
<div class="mx-auto px-8 sm:px-16 flex flex-col justify-center items-center text-center space-y-4 max-w-3xl">
 | 
			
		||||
  <h1 class="title text-primary-600 text-2xl">
 | 
			
		||||
    <%= gettext("Welcome to Cannery") %>
 | 
			
		||||
  </h1>
 | 
			
		||||
 | 
			
		||||
  <h2 class="title text-primary-600 text-lg">
 | 
			
		||||
    <%= gettext("Shop from your local community") %>
 | 
			
		||||
  </h2>
 | 
			
		||||
 | 
			
		||||
  <hr class="hr" />
 | 
			
		||||
 | 
			
		||||
  <ul class="flex flex-col space-y-4 text-center">
 | 
			
		||||
    <li class="flex flex-col justify-center items-center
 | 
			
		||||
      space-y-2">
 | 
			
		||||
      <b class="whitespace-nowrap">
 | 
			
		||||
        <%= gettext("Easy to Use:") %>
 | 
			
		||||
      </b>
 | 
			
		||||
      <p>
 | 
			
		||||
        <%= gettext("Cannery lets you easily shop from your local community") %>
 | 
			
		||||
      </p>
 | 
			
		||||
    </li>
 | 
			
		||||
    <li class="flex flex-col justify-center items-center space-y-2">
 | 
			
		||||
      <b class="whitespace-nowrap">
 | 
			
		||||
        <%= gettext("Secure:") %>
 | 
			
		||||
      </b>
 | 
			
		||||
      <p>
 | 
			
		||||
        <%= gettext("Self-host your own instance, or use an instance from someone you trust.") %>
 | 
			
		||||
        <%= gettext("Your data stays with you, period") %>
 | 
			
		||||
      </p>
 | 
			
		||||
    </li>
 | 
			
		||||
    <li class="flex flex-col justify-center items-center
 | 
			
		||||
      space-y-2">
 | 
			
		||||
      <b class="whitespace-nowrap">
 | 
			
		||||
        <%= gettext("Simple:") %>
 | 
			
		||||
      </b>
 | 
			
		||||
      <p>
 | 
			
		||||
        <%= gettext("Access from any internet-capable device") %>
 | 
			
		||||
      </p>
 | 
			
		||||
    </li>
 | 
			
		||||
  </ul>
 | 
			
		||||
 | 
			
		||||
  <hr class="hr" />
 | 
			
		||||
 | 
			
		||||
  <ul class="flex flex-col space-y-2 text-center justify-center">
 | 
			
		||||
    <h2 class="title text-primary-600 text-lg">
 | 
			
		||||
      <%= gettext("Instance Information") %>
 | 
			
		||||
    </h2>
 | 
			
		||||
 | 
			
		||||
    <li class="flex flex-col justify-center space-x-2">
 | 
			
		||||
      <b>
 | 
			
		||||
        <%= gettext("Admins:") %>
 | 
			
		||||
      </b>
 | 
			
		||||
      <p>
 | 
			
		||||
        <%= if @admins |> Enum.empty?() do %>
 | 
			
		||||
          <.link href={Routes.user_registration_path(Endpoint, :new)} class="hover:underline">
 | 
			
		||||
            <%= dgettext("prompts", "Register to setup Cannery") %>
 | 
			
		||||
          </.link>
 | 
			
		||||
        <% else %>
 | 
			
		||||
          <div class="flex flex-wrap justify-center space-x-2">
 | 
			
		||||
            <a :for={%{email: email} <- @admins} class="hover:underline" href={"mailto:#{email}"}>
 | 
			
		||||
              <%= email %>
 | 
			
		||||
            </a>
 | 
			
		||||
          </div>
 | 
			
		||||
        <% end %>
 | 
			
		||||
      </p>
 | 
			
		||||
    </li>
 | 
			
		||||
 | 
			
		||||
    <li class="flex flex-row justify-center space-x-2">
 | 
			
		||||
      <b><%= gettext("Registration:") %></b>
 | 
			
		||||
      <p>
 | 
			
		||||
        <%= case Application.get_env(:cannery, Cannery.Accounts)[:registration] do
 | 
			
		||||
          "public" -> gettext("Public Signups")
 | 
			
		||||
          _ -> gettext("Invite Only")
 | 
			
		||||
        end %>
 | 
			
		||||
      </p>
 | 
			
		||||
    </li>
 | 
			
		||||
 | 
			
		||||
    <li class="flex flex-row justify-center items-center space-x-2">
 | 
			
		||||
      <b><%= gettext("Version:") %></b>
 | 
			
		||||
      <.link
 | 
			
		||||
        href="https://gitea.bubbletea.dev/shibao/cannery/src/branch/stable/CHANGELOG.md"
 | 
			
		||||
        class="flex flex-row justify-center items-center space-x-2 hover:underline"
 | 
			
		||||
        target="_blank"
 | 
			
		||||
        rel="noopener noreferrer"
 | 
			
		||||
      >
 | 
			
		||||
        <p>
 | 
			
		||||
          <%= @version %>
 | 
			
		||||
        </p>
 | 
			
		||||
        <i class="fas fa-md fa-info-circle"></i>
 | 
			
		||||
      </.link>
 | 
			
		||||
    </li>
 | 
			
		||||
  </ul>
 | 
			
		||||
 | 
			
		||||
  <hr class="hr" />
 | 
			
		||||
 | 
			
		||||
  <ul class="flex flex-col space-y-2 text-center justify-center">
 | 
			
		||||
    <h2 class="title text-primary-600 text-lg">
 | 
			
		||||
      <%= gettext("Get involved!") %>
 | 
			
		||||
    </h2>
 | 
			
		||||
 | 
			
		||||
    <li class="flex flex-col justify-center space-x-2">
 | 
			
		||||
      <.link
 | 
			
		||||
        class="flex flex-row justify-center items-center space-x-2 hover:underline"
 | 
			
		||||
        href="https://gitea.bubbletea.dev/shibao/cannery"
 | 
			
		||||
        target="_blank"
 | 
			
		||||
        rel="noopener noreferrer"
 | 
			
		||||
      >
 | 
			
		||||
        <p><%= gettext("View the source code") %></p>
 | 
			
		||||
        <i class="fas fa-md fa-code"></i>
 | 
			
		||||
      </.link>
 | 
			
		||||
    </li>
 | 
			
		||||
    <li class="flex flex-col justify-center space-x-2">
 | 
			
		||||
      <.link
 | 
			
		||||
        class="flex flex-row justify-center items-center space-x-2 hover:underline"
 | 
			
		||||
        href="https://weblate.bubbletea.dev/engage/cannery"
 | 
			
		||||
        target="_blank"
 | 
			
		||||
        rel="noopener noreferrer"
 | 
			
		||||
      >
 | 
			
		||||
        <p><%= gettext("Help translate") %></p>
 | 
			
		||||
        <i class="fas fa-md fa-language"></i>
 | 
			
		||||
      </.link>
 | 
			
		||||
    </li>
 | 
			
		||||
    <li class="flex flex-col justify-center space-x-2">
 | 
			
		||||
      <.link
 | 
			
		||||
        class="flex flex-row justify-center items-center space-x-2 hover:underline"
 | 
			
		||||
        href="https://gitea.bubbletea.dev/shibao/cannery/issues/new"
 | 
			
		||||
        target="_blank"
 | 
			
		||||
        rel="noopener noreferrer"
 | 
			
		||||
      >
 | 
			
		||||
        <p><%= gettext("Report bugs or request features") %></p>
 | 
			
		||||
        <i class="fas fa-md fa-spider"></i>
 | 
			
		||||
      </.link>
 | 
			
		||||
    </li>
 | 
			
		||||
  </ul>
 | 
			
		||||
</div>
 | 
			
		||||
							
								
								
									
										19
									
								
								lib/cannery_web/live/init_assigns.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								lib/cannery_web/live/init_assigns.ex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,19 @@
 | 
			
		||||
defmodule CanneryWeb.InitAssigns do
 | 
			
		||||
  @moduledoc """
 | 
			
		||||
  Ensures common `assigns` are applied to all LiveViews attaching this hook.
 | 
			
		||||
  """
 | 
			
		||||
  import Phoenix.Component
 | 
			
		||||
  alias Cannery.Accounts
 | 
			
		||||
 | 
			
		||||
  def on_mount(:default, _params, %{"locale" => locale, "user_token" => user_token}, socket) do
 | 
			
		||||
    Gettext.put_locale(locale)
 | 
			
		||||
 | 
			
		||||
    socket =
 | 
			
		||||
      socket
 | 
			
		||||
      |> assign_new(:current_user, fn -> Accounts.get_user_by_session_token(user_token) end)
 | 
			
		||||
 | 
			
		||||
    {:cont, socket}
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def on_mount(:default, _params, _session, socket), do: {:cont, socket}
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										89
									
								
								lib/cannery_web/live/invite_live/form_component.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								lib/cannery_web/live/invite_live/form_component.ex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,89 @@
 | 
			
		||||
defmodule CanneryWeb.InviteLive.FormComponent do
 | 
			
		||||
  @moduledoc """
 | 
			
		||||
  Livecomponent that can update or create an Cannery.Accounts.Invite
 | 
			
		||||
  """
 | 
			
		||||
 | 
			
		||||
  use CanneryWeb, :live_component
 | 
			
		||||
  alias Ecto.Changeset
 | 
			
		||||
  alias Cannery.Accounts.{Invite, Invites, User}
 | 
			
		||||
  alias Phoenix.LiveView.Socket
 | 
			
		||||
 | 
			
		||||
  @impl true
 | 
			
		||||
  @spec update(
 | 
			
		||||
          %{:invite => Invite.t(), :current_user => User.t(), optional(any) => any},
 | 
			
		||||
          Socket.t()
 | 
			
		||||
        ) :: {:ok, Socket.t()}
 | 
			
		||||
  def update(%{invite: _invite} = assigns, socket) do
 | 
			
		||||
    {:ok, socket |> assign(assigns) |> assign_changeset(%{})}
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  @impl true
 | 
			
		||||
  def handle_event("validate", %{"invite" => invite_params}, socket) do
 | 
			
		||||
    {:noreply, socket |> assign_changeset(invite_params)}
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def handle_event("save", %{"invite" => invite_params}, %{assigns: %{action: action}} = socket) do
 | 
			
		||||
    save_invite(socket, action, invite_params)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  defp assign_changeset(
 | 
			
		||||
         %{assigns: %{action: action, current_user: user, invite: invite}} = socket,
 | 
			
		||||
         invite_params
 | 
			
		||||
       ) do
 | 
			
		||||
    changeset_action =
 | 
			
		||||
      case action do
 | 
			
		||||
        :new -> :insert
 | 
			
		||||
        :edit -> :update
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
    changeset =
 | 
			
		||||
      case action do
 | 
			
		||||
        :new -> Invite.create_changeset(user, "example_token", invite_params)
 | 
			
		||||
        :edit -> invite |> Invite.update_changeset(invite_params)
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
    changeset =
 | 
			
		||||
      case changeset |> Changeset.apply_action(changeset_action) do
 | 
			
		||||
        {:ok, _data} -> changeset
 | 
			
		||||
        {:error, changeset} -> changeset
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
    socket |> assign(:changeset, changeset)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  defp save_invite(
 | 
			
		||||
         %{assigns: %{current_user: current_user, invite: invite, return_to: return_to}} = socket,
 | 
			
		||||
         :edit,
 | 
			
		||||
         invite_params
 | 
			
		||||
       ) do
 | 
			
		||||
    socket =
 | 
			
		||||
      case invite |> Invites.update_invite(invite_params, current_user) do
 | 
			
		||||
        {:ok, %{name: invite_name}} ->
 | 
			
		||||
          prompt = dgettext("prompts", "%{name} updated successfully", name: invite_name)
 | 
			
		||||
          socket |> put_flash(:info, prompt) |> push_navigate(to: return_to)
 | 
			
		||||
 | 
			
		||||
        {:error, %Changeset{} = changeset} ->
 | 
			
		||||
          socket |> assign(:changeset, changeset)
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
    {:noreply, socket}
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  defp save_invite(
 | 
			
		||||
         %{assigns: %{current_user: current_user, return_to: return_to}} = socket,
 | 
			
		||||
         :new,
 | 
			
		||||
         invite_params
 | 
			
		||||
       ) do
 | 
			
		||||
    socket =
 | 
			
		||||
      case current_user |> Invites.create_invite(invite_params) do
 | 
			
		||||
        {:ok, %{name: invite_name}} ->
 | 
			
		||||
          prompt = dgettext("prompts", "%{name} created successfully", name: invite_name)
 | 
			
		||||
          socket |> put_flash(:info, prompt) |> push_navigate(to: return_to)
 | 
			
		||||
 | 
			
		||||
        {:error, %Changeset{} = changeset} ->
 | 
			
		||||
          socket |> assign(changeset: changeset)
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
    {:noreply, socket}
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										34
									
								
								lib/cannery_web/live/invite_live/form_component.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								lib/cannery_web/live/invite_live/form_component.html.heex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,34 @@
 | 
			
		||||
<div>
 | 
			
		||||
  <h2 class="mb-8 text-center title text-xl text-primary-600">
 | 
			
		||||
    <%= @title %>
 | 
			
		||||
  </h2>
 | 
			
		||||
  <.form
 | 
			
		||||
    :let={f}
 | 
			
		||||
    for={@changeset}
 | 
			
		||||
    id="invite-form"
 | 
			
		||||
    class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center"
 | 
			
		||||
    phx-target={@myself}
 | 
			
		||||
    phx-change="validate"
 | 
			
		||||
    phx-submit="save"
 | 
			
		||||
  >
 | 
			
		||||
    <div
 | 
			
		||||
      :if={@changeset.action && not @changeset.valid?()}
 | 
			
		||||
      class="invalid-feedback col-span-3 text-center"
 | 
			
		||||
    >
 | 
			
		||||
      <%= changeset_errors(@changeset) %>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <%= label(f, :name, gettext("Name"), class: "title text-lg text-primary-600") %>
 | 
			
		||||
    <%= text_input(f, :name, class: "input input-primary col-span-2") %>
 | 
			
		||||
    <%= error_tag(f, :name, "col-span-3") %>
 | 
			
		||||
 | 
			
		||||
    <%= label(f, :uses_left, gettext("Uses left"), class: "title text-lg text-primary-600") %>
 | 
			
		||||
    <%= number_input(f, :uses_left, min: 0, class: "input input-primary col-span-2") %>
 | 
			
		||||
    <%= error_tag(f, :uses_left, "col-span-3") %>
 | 
			
		||||
 | 
			
		||||
    <%= submit(dgettext("actions", "Save"),
 | 
			
		||||
      class: "mx-auto btn btn-primary col-span-3",
 | 
			
		||||
      phx_disable_with: dgettext("prompts", "Saving...")
 | 
			
		||||
    ) %>
 | 
			
		||||
  </.form>
 | 
			
		||||
</div>
 | 
			
		||||
							
								
								
									
										157
									
								
								lib/cannery_web/live/invite_live/index.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										157
									
								
								lib/cannery_web/live/invite_live/index.ex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,157 @@
 | 
			
		||||
defmodule CanneryWeb.InviteLive.Index do
 | 
			
		||||
  @moduledoc """
 | 
			
		||||
  Liveview to show a Cannery.Accounts.Invite index
 | 
			
		||||
  """
 | 
			
		||||
 | 
			
		||||
  use CanneryWeb, :live_view
 | 
			
		||||
  import CanneryWeb.Components.{InviteCard, UserCard}
 | 
			
		||||
  alias Cannery.Accounts
 | 
			
		||||
  alias Cannery.Accounts.{Invite, Invites}
 | 
			
		||||
  alias CanneryWeb.{Endpoint, HomeLive}
 | 
			
		||||
  alias Phoenix.LiveView.JS
 | 
			
		||||
 | 
			
		||||
  @impl true
 | 
			
		||||
  def mount(_params, _session, %{assigns: %{current_user: current_user}} = socket) do
 | 
			
		||||
    socket =
 | 
			
		||||
      if current_user |> Map.get(:role) == :admin do
 | 
			
		||||
        socket |> display_invites()
 | 
			
		||||
      else
 | 
			
		||||
        prompt = dgettext("errors", "You are not authorized to view this page")
 | 
			
		||||
        return_to = Routes.live_path(Endpoint, HomeLive)
 | 
			
		||||
        socket |> put_flash(:error, prompt) |> push_redirect(to: return_to)
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
    {:ok, socket}
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  @impl true
 | 
			
		||||
  def handle_params(params, _url, %{assigns: %{live_action: live_action}} = socket) do
 | 
			
		||||
    {:noreply, socket |> apply_action(live_action, params)}
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  defp apply_action(%{assigns: %{current_user: current_user}} = socket, :edit, %{"id" => id}) do
 | 
			
		||||
    socket
 | 
			
		||||
    |> assign(page_title: gettext("Edit Invite"), invite: Invites.get_invite!(id, current_user))
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  defp apply_action(socket, :new, _params) do
 | 
			
		||||
    socket |> assign(page_title: gettext("New Invite"), invite: %Invite{})
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  defp apply_action(socket, :index, _params) do
 | 
			
		||||
    socket |> assign(page_title: gettext("Invites"), invite: nil)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  @impl true
 | 
			
		||||
  def handle_event(
 | 
			
		||||
        "delete_invite",
 | 
			
		||||
        %{"id" => id},
 | 
			
		||||
        %{assigns: %{current_user: current_user}} = socket
 | 
			
		||||
      ) do
 | 
			
		||||
    %{name: invite_name} =
 | 
			
		||||
      id |> Invites.get_invite!(current_user) |> Invites.delete_invite!(current_user)
 | 
			
		||||
 | 
			
		||||
    prompt = dgettext("prompts", "%{invite_name} deleted succesfully", invite_name: invite_name)
 | 
			
		||||
    {:noreply, socket |> put_flash(:info, prompt) |> display_invites()}
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def handle_event(
 | 
			
		||||
        "set_unlimited",
 | 
			
		||||
        %{"id" => id},
 | 
			
		||||
        %{assigns: %{current_user: current_user}} = socket
 | 
			
		||||
      ) do
 | 
			
		||||
    socket =
 | 
			
		||||
      Invites.get_invite!(id, current_user)
 | 
			
		||||
      |> Invites.update_invite(%{uses_left: nil}, current_user)
 | 
			
		||||
      |> case do
 | 
			
		||||
        {:ok, %{name: invite_name}} ->
 | 
			
		||||
          prompt =
 | 
			
		||||
            dgettext("prompts", "%{invite_name} updated succesfully", invite_name: invite_name)
 | 
			
		||||
 | 
			
		||||
          socket |> put_flash(:info, prompt) |> display_invites()
 | 
			
		||||
 | 
			
		||||
        {:error, changeset} ->
 | 
			
		||||
          socket |> put_flash(:error, changeset |> changeset_errors())
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
    {:noreply, socket}
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def handle_event(
 | 
			
		||||
        "enable_invite",
 | 
			
		||||
        %{"id" => id},
 | 
			
		||||
        %{assigns: %{current_user: current_user}} = socket
 | 
			
		||||
      ) do
 | 
			
		||||
    socket =
 | 
			
		||||
      Invites.get_invite!(id, current_user)
 | 
			
		||||
      |> Invites.update_invite(%{uses_left: nil, disabled_at: nil}, current_user)
 | 
			
		||||
      |> case do
 | 
			
		||||
        {:ok, %{name: invite_name}} ->
 | 
			
		||||
          prompt =
 | 
			
		||||
            dgettext("prompts", "%{invite_name} enabled succesfully", invite_name: invite_name)
 | 
			
		||||
 | 
			
		||||
          socket |> put_flash(:info, prompt) |> display_invites()
 | 
			
		||||
 | 
			
		||||
        {:error, changeset} ->
 | 
			
		||||
          socket |> put_flash(:error, changeset |> changeset_errors())
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
    {:noreply, socket}
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def handle_event(
 | 
			
		||||
        "disable_invite",
 | 
			
		||||
        %{"id" => id},
 | 
			
		||||
        %{assigns: %{current_user: current_user}} = socket
 | 
			
		||||
      ) do
 | 
			
		||||
    now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
 | 
			
		||||
 | 
			
		||||
    socket =
 | 
			
		||||
      Invites.get_invite!(id, current_user)
 | 
			
		||||
      |> Invites.update_invite(%{uses_left: 0, disabled_at: now}, current_user)
 | 
			
		||||
      |> case do
 | 
			
		||||
        {:ok, %{name: invite_name}} ->
 | 
			
		||||
          prompt =
 | 
			
		||||
            dgettext("prompts", "%{invite_name} disabled succesfully", invite_name: invite_name)
 | 
			
		||||
 | 
			
		||||
          socket |> put_flash(:info, prompt) |> display_invites()
 | 
			
		||||
 | 
			
		||||
        {:error, changeset} ->
 | 
			
		||||
          socket |> put_flash(:error, changeset |> changeset_errors())
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
    {:noreply, socket}
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  @impl true
 | 
			
		||||
  def handle_event("copy_to_clipboard", _params, socket) do
 | 
			
		||||
    prompt = dgettext("prompts", "Copied to clipboard")
 | 
			
		||||
    {:noreply, socket |> put_flash(:info, prompt)}
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  @impl true
 | 
			
		||||
  def handle_event(
 | 
			
		||||
        "delete_user",
 | 
			
		||||
        %{"id" => id},
 | 
			
		||||
        %{assigns: %{current_user: current_user}} = socket
 | 
			
		||||
      ) do
 | 
			
		||||
    %{email: user_email} = Accounts.get_user!(id) |> Accounts.delete_user!(current_user)
 | 
			
		||||
 | 
			
		||||
    prompt = dgettext("prompts", "%{user_email} deleted succesfully", user_email: user_email)
 | 
			
		||||
 | 
			
		||||
    {:noreply, socket |> put_flash(:info, prompt) |> display_invites()}
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  defp display_invites(%{assigns: %{current_user: current_user}} = socket) do
 | 
			
		||||
    invites = Invites.list_invites(current_user)
 | 
			
		||||
    all_users = Accounts.list_all_users_by_role(current_user)
 | 
			
		||||
 | 
			
		||||
    admins =
 | 
			
		||||
      all_users
 | 
			
		||||
      |> Map.get(:admin, [])
 | 
			
		||||
      |> Enum.reject(fn %{id: user_id} -> user_id == current_user.id end)
 | 
			
		||||
 | 
			
		||||
    users = all_users |> Map.get(:user, [])
 | 
			
		||||
    socket |> assign(invites: invites, admins: admins, users: users)
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										149
									
								
								lib/cannery_web/live/invite_live/index.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										149
									
								
								lib/cannery_web/live/invite_live/index.html.heex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,149 @@
 | 
			
		||||
<div class="w-full flex flex-col space-y-8 justify-center items-center">
 | 
			
		||||
  <h1 class="title text-2xl title-primary-500">
 | 
			
		||||
    <%= gettext("Invites") %>
 | 
			
		||||
  </h1>
 | 
			
		||||
 | 
			
		||||
  <%= if @invites |> Enum.empty?() do %>
 | 
			
		||||
    <h1 class="title text-xl text-primary-600">
 | 
			
		||||
      <%= gettext("No invites 😔") %>
 | 
			
		||||
    </h1>
 | 
			
		||||
 | 
			
		||||
    <.link patch={Routes.invite_index_path(Endpoint, :new)} class="btn btn-primary">
 | 
			
		||||
      <%= dgettext("actions", "Invite someone new!") %>
 | 
			
		||||
    </.link>
 | 
			
		||||
  <% else %>
 | 
			
		||||
    <.link patch={Routes.invite_index_path(Endpoint, :new)} class="btn btn-primary">
 | 
			
		||||
      <%= dgettext("actions", "Create Invite") %>
 | 
			
		||||
    </.link>
 | 
			
		||||
  <% end %>
 | 
			
		||||
 | 
			
		||||
  <div class="w-full flex flex-row flex-wrap justify-center items-center">
 | 
			
		||||
    <.invite_card :for={invite <- @invites} invite={invite} current_user={@current_user}>
 | 
			
		||||
      <:code_actions>
 | 
			
		||||
        <form phx-submit="copy_to_clipboard">
 | 
			
		||||
          <button
 | 
			
		||||
            type="submit"
 | 
			
		||||
            class="mx-2 my-1 btn btn-primary"
 | 
			
		||||
            phx-click={JS.dispatch("cannery:clipcopy", to: "#code-#{invite.id}")}
 | 
			
		||||
          >
 | 
			
		||||
            <%= dgettext("actions", "Copy to clipboard") %>
 | 
			
		||||
          </button>
 | 
			
		||||
        </form>
 | 
			
		||||
      </:code_actions>
 | 
			
		||||
      <.link
 | 
			
		||||
        patch={Routes.invite_index_path(Endpoint, :edit, invite)}
 | 
			
		||||
        class="text-primary-600 link"
 | 
			
		||||
        data-qa={"edit-#{invite.id}"}
 | 
			
		||||
      >
 | 
			
		||||
        <i class="fa-fw fa-lg fas fa-edit"></i>
 | 
			
		||||
      </.link>
 | 
			
		||||
 | 
			
		||||
      <.link
 | 
			
		||||
        href="#"
 | 
			
		||||
        class="text-primary-600 link"
 | 
			
		||||
        phx-click="delete_invite"
 | 
			
		||||
        phx-value-id={invite.id}
 | 
			
		||||
        data-confirm={
 | 
			
		||||
          dgettext("prompts", "Are you sure you want to delete the invite for %{invite_name}?",
 | 
			
		||||
            invite_name: invite.name
 | 
			
		||||
          )
 | 
			
		||||
        }
 | 
			
		||||
        data-qa={"delete-#{invite.id}"}
 | 
			
		||||
      >
 | 
			
		||||
        <i class="fa-fw fa-lg fas fa-trash"></i>
 | 
			
		||||
      </.link>
 | 
			
		||||
 | 
			
		||||
      <a
 | 
			
		||||
        href="#"
 | 
			
		||||
        class="btn btn-primary"
 | 
			
		||||
        phx-click={if invite.disabled_at, do: "enable_invite", else: "disable_invite"}
 | 
			
		||||
        phx-value-id={invite.id}
 | 
			
		||||
      >
 | 
			
		||||
        <%= if invite.disabled_at, do: gettext("Enable"), else: gettext("Disable") %>
 | 
			
		||||
      </a>
 | 
			
		||||
 | 
			
		||||
      <a
 | 
			
		||||
        :if={invite.disabled_at |> is_nil() and not (invite.uses_left |> is_nil())}
 | 
			
		||||
        href="#"
 | 
			
		||||
        class="btn btn-primary"
 | 
			
		||||
        phx-click="set_unlimited"
 | 
			
		||||
        phx-value-id={invite.id}
 | 
			
		||||
        data-confirm={
 | 
			
		||||
          dgettext("prompts", "Are you sure you want to make %{invite_name} unlimited?",
 | 
			
		||||
            invite_name: invite.name
 | 
			
		||||
          )
 | 
			
		||||
        }
 | 
			
		||||
      >
 | 
			
		||||
        <%= gettext("Set Unlimited") %>
 | 
			
		||||
      </a>
 | 
			
		||||
    </.invite_card>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  <%= unless @admins |> Enum.empty?() do %>
 | 
			
		||||
    <hr class="hr" />
 | 
			
		||||
 | 
			
		||||
    <h1 class="title text-2xl text-primary-600">
 | 
			
		||||
      <%= gettext("Admins") %>
 | 
			
		||||
    </h1>
 | 
			
		||||
 | 
			
		||||
    <div class="w-full flex flex-row flex-wrap justify-center items-center">
 | 
			
		||||
      <.user_card :for={admin <- @admins} user={admin}>
 | 
			
		||||
        <.link
 | 
			
		||||
          href="#"
 | 
			
		||||
          class="text-primary-600 link"
 | 
			
		||||
          phx-click="delete_user"
 | 
			
		||||
          phx-value-id={admin.id}
 | 
			
		||||
          data-confirm={
 | 
			
		||||
            dgettext(
 | 
			
		||||
              "prompts",
 | 
			
		||||
              "Are you sure you want to delete %{email}? This action is permanent!",
 | 
			
		||||
              email: admin.email
 | 
			
		||||
            )
 | 
			
		||||
          }
 | 
			
		||||
        >
 | 
			
		||||
          <i class="fa-fw fa-lg fas fa-trash"></i>
 | 
			
		||||
        </.link>
 | 
			
		||||
      </.user_card>
 | 
			
		||||
    </div>
 | 
			
		||||
  <% end %>
 | 
			
		||||
 | 
			
		||||
  <%= unless @users |> Enum.empty?() do %>
 | 
			
		||||
    <hr class="hr" />
 | 
			
		||||
 | 
			
		||||
    <h1 class="title text-2xl text-primary-600">
 | 
			
		||||
      <%= gettext("Users") %>
 | 
			
		||||
    </h1>
 | 
			
		||||
 | 
			
		||||
    <div class="w-full flex flex-row flex-wrap justify-center items-center">
 | 
			
		||||
      <.user_card :for={user <- @users} user={user}>
 | 
			
		||||
        <.link
 | 
			
		||||
          href="#"
 | 
			
		||||
          class="text-primary-600 link"
 | 
			
		||||
          phx-click="delete_user"
 | 
			
		||||
          phx-value-id={user.id}
 | 
			
		||||
          data-confirm={
 | 
			
		||||
            dgettext(
 | 
			
		||||
              "prompts",
 | 
			
		||||
              "Are you sure you want to delete %{email}? This action is permanent!",
 | 
			
		||||
              email: user.email
 | 
			
		||||
            )
 | 
			
		||||
          }
 | 
			
		||||
        >
 | 
			
		||||
          <i class="fa-fw fa-lg fas fa-trash"></i>
 | 
			
		||||
        </.link>
 | 
			
		||||
      </.user_card>
 | 
			
		||||
    </div>
 | 
			
		||||
  <% end %>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<.modal :if={@live_action in [:new, :edit]} return_to={Routes.invite_index_path(Endpoint, :index)}>
 | 
			
		||||
  <.live_component
 | 
			
		||||
    module={CanneryWeb.InviteLive.FormComponent}
 | 
			
		||||
    id={@invite.id || :new}
 | 
			
		||||
    title={@page_title}
 | 
			
		||||
    action={@live_action}
 | 
			
		||||
    invite={@invite}
 | 
			
		||||
    return_to={Routes.invite_index_path(Endpoint, :index)}
 | 
			
		||||
    current_user={@current_user}
 | 
			
		||||
  />
 | 
			
		||||
</.modal>
 | 
			
		||||
							
								
								
									
										81
									
								
								lib/cannery_web/live/live_helpers.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								lib/cannery_web/live/live_helpers.ex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,81 @@
 | 
			
		||||
defmodule CanneryWeb.LiveHelpers do
 | 
			
		||||
  @moduledoc """
 | 
			
		||||
  Contains resuable methods for all liveviews
 | 
			
		||||
  """
 | 
			
		||||
 | 
			
		||||
  import Phoenix.Component
 | 
			
		||||
  alias Phoenix.LiveView.JS
 | 
			
		||||
 | 
			
		||||
  @doc """
 | 
			
		||||
  Renders a live component inside a modal.
 | 
			
		||||
 | 
			
		||||
  The rendered modal receives a `:return_to` option to properly update
 | 
			
		||||
  the URL when the modal is closed.
 | 
			
		||||
 | 
			
		||||
  ## Examples
 | 
			
		||||
 | 
			
		||||
      <.modal return_to={Routes.<%= schema.singular %>_index_path(Endpoint, :index)}>
 | 
			
		||||
        <.live_component
 | 
			
		||||
          module={<%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>Live.FormComponent}
 | 
			
		||||
          id={@<%= schema.singular %>.id || :new}
 | 
			
		||||
          title={@page_title}
 | 
			
		||||
          action={@live_action}
 | 
			
		||||
          return_to={Routes.<%= schema.singular %>_index_path(Endpoint, :index)}
 | 
			
		||||
          <%= schema.singular %>: @<%= schema.singular %>
 | 
			
		||||
        />
 | 
			
		||||
      </.modal>
 | 
			
		||||
  """
 | 
			
		||||
  def modal(assigns) do
 | 
			
		||||
    ~H"""
 | 
			
		||||
    <.link
 | 
			
		||||
      id="modal-bg"
 | 
			
		||||
      patch={@return_to}
 | 
			
		||||
      class="fade-in fixed z-10 left-0 top-0
 | 
			
		||||
        w-full h-full overflow-hidden
 | 
			
		||||
        p-8 flex flex-col justify-center items-center cursor-auto"
 | 
			
		||||
      style="background-color: rgba(0,0,0,0.4);"
 | 
			
		||||
      phx-remove={hide_modal()}
 | 
			
		||||
    >
 | 
			
		||||
      <span class="hidden"></span>
 | 
			
		||||
    </.link>
 | 
			
		||||
 | 
			
		||||
    <div
 | 
			
		||||
      id="modal"
 | 
			
		||||
      class="fixed z-10 left-0 top-0 pointer-events-none
 | 
			
		||||
        w-full h-full overflow-hidden
 | 
			
		||||
        p-4 sm:p-8 flex flex-col justify-center items-center"
 | 
			
		||||
    >
 | 
			
		||||
      <div
 | 
			
		||||
        id="modal-content"
 | 
			
		||||
        class="fade-in-scale w-full max-w-3xl relative
 | 
			
		||||
          pointer-events-auto overflow-hidden
 | 
			
		||||
          px-8 py-4 sm:py-8
 | 
			
		||||
          flex flex-col justify-start items-center
 | 
			
		||||
          bg-white border-2 rounded-lg"
 | 
			
		||||
      >
 | 
			
		||||
        <.link
 | 
			
		||||
          id="close"
 | 
			
		||||
          href={@return_to}
 | 
			
		||||
          class="absolute top-8 right-10
 | 
			
		||||
            text-gray-500 hover:text-gray-800
 | 
			
		||||
            transition-all duration-500 ease-in-out"
 | 
			
		||||
          phx-remove={hide_modal()}
 | 
			
		||||
        >
 | 
			
		||||
          <i class="fa-fw fa-lg fas fa-times"></i>
 | 
			
		||||
        </.link>
 | 
			
		||||
 | 
			
		||||
        <div class="overflow-x-hidden overflow-y-auto w-full p-8 flex flex-col space-y-4 justify-start items-center">
 | 
			
		||||
          <%= render_slot(@inner_block) %>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    """
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def hide_modal(js \\ %JS{}) do
 | 
			
		||||
    js
 | 
			
		||||
    |> JS.hide(to: "#modal", transition: "fade-out")
 | 
			
		||||
    |> JS.hide(to: "#modal-bg", transition: "fade-out")
 | 
			
		||||
    |> JS.hide(to: "#modal-content", transition: "fade-out-scale")
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										101
									
								
								lib/cannery_web/router.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										101
									
								
								lib/cannery_web/router.ex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,101 @@
 | 
			
		||||
defmodule CanneryWeb.Router do
 | 
			
		||||
  use CanneryWeb, :router
 | 
			
		||||
  import Phoenix.LiveDashboard.Router
 | 
			
		||||
  import CanneryWeb.UserAuth
 | 
			
		||||
 | 
			
		||||
  pipeline :browser do
 | 
			
		||||
    plug :accepts, ["html"]
 | 
			
		||||
    plug :fetch_session
 | 
			
		||||
    plug :fetch_live_flash
 | 
			
		||||
    plug :put_root_layout, {CanneryWeb.LayoutView, :root}
 | 
			
		||||
    plug :protect_from_forgery
 | 
			
		||||
    plug :put_secure_browser_headers
 | 
			
		||||
    plug :fetch_current_user
 | 
			
		||||
    plug :put_user_locale
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  defp put_user_locale(%{assigns: %{current_user: %{locale: locale}}} = conn, _opts) do
 | 
			
		||||
    default = Application.fetch_env!(:gettext, :default_locale)
 | 
			
		||||
    Gettext.put_locale(locale || default)
 | 
			
		||||
    conn |> put_session(:locale, locale || default)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  defp put_user_locale(conn, _opts) do
 | 
			
		||||
    default = Application.fetch_env!(:gettext, :default_locale)
 | 
			
		||||
    Gettext.put_locale(default)
 | 
			
		||||
    conn |> put_session(:locale, default)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  pipeline :require_admin do
 | 
			
		||||
    plug :require_role, role: :admin
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  pipeline :api do
 | 
			
		||||
    plug :accepts, ["json"]
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  scope "/", CanneryWeb do
 | 
			
		||||
    pipe_through :browser
 | 
			
		||||
 | 
			
		||||
    live "/", HomeLive
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  ## Authentication routes
 | 
			
		||||
 | 
			
		||||
  scope "/", CanneryWeb do
 | 
			
		||||
    pipe_through [:browser, :redirect_if_user_is_authenticated]
 | 
			
		||||
 | 
			
		||||
    get "/users/register", UserRegistrationController, :new
 | 
			
		||||
    post "/users/register", UserRegistrationController, :create
 | 
			
		||||
    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
 | 
			
		||||
    put "/users/reset_password/:token", UserResetPasswordController, :update
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  scope "/", CanneryWeb do
 | 
			
		||||
    pipe_through [:browser, :require_authenticated_user]
 | 
			
		||||
 | 
			
		||||
    get "/users/settings", UserSettingsController, :edit
 | 
			
		||||
    put "/users/settings", UserSettingsController, :update
 | 
			
		||||
    delete "/users/settings/:id", UserSettingsController, :delete
 | 
			
		||||
    get "/users/settings/confirm_email/:token", UserSettingsController, :confirm_email
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  scope "/", CanneryWeb do
 | 
			
		||||
    pipe_through [:browser, :require_authenticated_user, :require_admin]
 | 
			
		||||
 | 
			
		||||
    live_dashboard "/dashboard", metrics: CanneryWeb.Telemetry, ecto_repos: [Cannery.Repo]
 | 
			
		||||
 | 
			
		||||
    live "/invites", InviteLive.Index, :index
 | 
			
		||||
    live "/invites/new", InviteLive.Index, :new
 | 
			
		||||
    live "/invites/:id/edit", InviteLive.Index, :edit
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  scope "/", CanneryWeb do
 | 
			
		||||
    pipe_through [:browser]
 | 
			
		||||
 | 
			
		||||
    delete "/users/log_out", UserSessionController, :delete
 | 
			
		||||
    get "/users/confirm", UserConfirmationController, :new
 | 
			
		||||
    post "/users/confirm", UserConfirmationController, :create
 | 
			
		||||
    get "/users/confirm/:token", UserConfirmationController, :confirm
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # Enables the Swoosh mailbox preview in development.
 | 
			
		||||
  #
 | 
			
		||||
  # Note that preview only shows emails that were sent by the same
 | 
			
		||||
  # node running the Phoenix server.
 | 
			
		||||
  if Mix.env() == :dev do
 | 
			
		||||
    scope "/dev" do
 | 
			
		||||
      pipe_through :browser
 | 
			
		||||
 | 
			
		||||
      forward "/mailbox", Plug.Swoosh.MailboxPreview
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    scope "/dev" do
 | 
			
		||||
      get "/preview/:id", CanneryWeb.EmailController, :preview
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										99
									
								
								lib/cannery_web/telemetry.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								lib/cannery_web/telemetry.ex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,99 @@
 | 
			
		||||
defmodule CanneryWeb.Telemetry do
 | 
			
		||||
  @moduledoc """
 | 
			
		||||
  Collects telemetry
 | 
			
		||||
  """
 | 
			
		||||
 | 
			
		||||
  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("cannery.repo.query.total_time",
 | 
			
		||||
        unit: {:native, :millisecond},
 | 
			
		||||
        description: "The sum of the other measurements"
 | 
			
		||||
      ),
 | 
			
		||||
      summary("cannery.repo.query.decode_time",
 | 
			
		||||
        unit: {:native, :millisecond},
 | 
			
		||||
        description: "The time spent decoding the data received from the database"
 | 
			
		||||
      ),
 | 
			
		||||
      summary("cannery.repo.query.query_time",
 | 
			
		||||
        unit: {:native, :millisecond},
 | 
			
		||||
        description: "The time spent executing the query"
 | 
			
		||||
      ),
 | 
			
		||||
      summary("cannery.repo.query.queue_time",
 | 
			
		||||
        unit: {:native, :millisecond},
 | 
			
		||||
        description: "The time spent waiting for a database connection"
 | 
			
		||||
      ),
 | 
			
		||||
      summary("cannery.repo.query.idle_time",
 | 
			
		||||
        unit: {:native, :millisecond},
 | 
			
		||||
        description:
 | 
			
		||||
          "The time the connection spent waiting before being checked out for the query"
 | 
			
		||||
      ),
 | 
			
		||||
 | 
			
		||||
      # Oban Metrics
 | 
			
		||||
      counter("oban.job.exception",
 | 
			
		||||
        tags: [:queue, :worker],
 | 
			
		||||
        event_name: [:oban, :job, :exception],
 | 
			
		||||
        measurement: :duration,
 | 
			
		||||
        description: "Number of oban jobs that raised an exception"
 | 
			
		||||
      ),
 | 
			
		||||
      counter("oban.job.start",
 | 
			
		||||
        tags: [:queue, :worker],
 | 
			
		||||
        event_name: [:oban, :job, :start],
 | 
			
		||||
        measurement: :system_time,
 | 
			
		||||
        description: "Number of oban jobs started"
 | 
			
		||||
      ),
 | 
			
		||||
      summary("oban.job.stop.duration",
 | 
			
		||||
        tags: [:queue, :worker],
 | 
			
		||||
        unit: {:native, :millisecond},
 | 
			
		||||
        description: "Length of time spent processing the oban job"
 | 
			
		||||
      ),
 | 
			
		||||
      summary("oban.job.stop.queue_time",
 | 
			
		||||
        tags: [:queue, :worker],
 | 
			
		||||
        unit: {:native, :millisecond},
 | 
			
		||||
        description: "Time the oban job spent waiting in milliseconds"
 | 
			
		||||
      ),
 | 
			
		||||
 | 
			
		||||
      # 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.
 | 
			
		||||
      # {CanneryWeb, :count_users, []}
 | 
			
		||||
    ]
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										23
									
								
								lib/cannery_web/templates/email/confirm_email.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								lib/cannery_web/templates/email/confirm_email.html.heex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,23 @@
 | 
			
		||||
<div style="display: flex; flex-direction: column; justify-content: center; align-items: center;">
 | 
			
		||||
  <span style="margin-bottom: 0.75em; font-size: 1.5em;">
 | 
			
		||||
    <%= dgettext("emails", "Hi %{email},", email: @user.email) %>
 | 
			
		||||
  </span>
 | 
			
		||||
 | 
			
		||||
  <br />
 | 
			
		||||
 | 
			
		||||
  <span style="margin-bottom: 1em; font-size: 1.25em;">
 | 
			
		||||
    <%= dgettext("emails", "Welcome to Cannery") %>
 | 
			
		||||
  </span>
 | 
			
		||||
 | 
			
		||||
  <br />
 | 
			
		||||
 | 
			
		||||
  <%= dgettext("emails", "You can confirm your account by visiting the URL below:") %>
 | 
			
		||||
 | 
			
		||||
  <br />
 | 
			
		||||
 | 
			
		||||
  <a style="margin: 1em; color: rgb(31, 31, 31);" href={@url}><%= @url %></a>
 | 
			
		||||
 | 
			
		||||
  <br />
 | 
			
		||||
 | 
			
		||||
  <%= dgettext("emails", "If you didn't create an account at Cannery, please ignore this.") %>
 | 
			
		||||
</div>
 | 
			
		||||
							
								
								
									
										12
									
								
								lib/cannery_web/templates/email/confirm_email.txt.eex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								lib/cannery_web/templates/email/confirm_email.txt.eex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,12 @@
 | 
			
		||||
 | 
			
		||||
<%= dgettext("emails", "Hi %{email},", email: @user.email) %>
 | 
			
		||||
 | 
			
		||||
<%= dgettext("emails", "Welcome to Cannery") %>
 | 
			
		||||
 | 
			
		||||
<%= dgettext("emails", "You can confirm your account by visiting the URL below:") %>
 | 
			
		||||
 | 
			
		||||
<%= @url %>
 | 
			
		||||
 | 
			
		||||
<%= dgettext("emails",
 | 
			
		||||
  "If you didn't create an account at %{url}, please ignore this.",
 | 
			
		||||
  url: Routes.live_url(Endpoint, HomeLive)) %>
 | 
			
		||||
							
								
								
									
										17
									
								
								lib/cannery_web/templates/email/reset_password.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								lib/cannery_web/templates/email/reset_password.html.heex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,17 @@
 | 
			
		||||
<div style="display: flex; flex-direction: column; justify-content: center; align-items: center;">
 | 
			
		||||
  <span style="margin-bottom: 0.5em; font-size: 1.5em;">
 | 
			
		||||
    <%= dgettext("emails", "Hi %{email},", email: @user.email) %>
 | 
			
		||||
  </span>
 | 
			
		||||
 | 
			
		||||
  <br />
 | 
			
		||||
 | 
			
		||||
  <%= dgettext("emails", "You can reset your password by visiting the URL below:") %>
 | 
			
		||||
 | 
			
		||||
  <br />
 | 
			
		||||
 | 
			
		||||
  <a style="margin: 1em; color: rgb(31, 31, 31);" href={@url}><%= @url %></a>
 | 
			
		||||
 | 
			
		||||
  <br />
 | 
			
		||||
 | 
			
		||||
  <%= dgettext("emails", "If you didn't request this change from Cannery, please ignore this.") %>
 | 
			
		||||
</div>
 | 
			
		||||
							
								
								
									
										10
									
								
								lib/cannery_web/templates/email/reset_password.txt.eex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								lib/cannery_web/templates/email/reset_password.txt.eex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,10 @@
 | 
			
		||||
 | 
			
		||||
<%= dgettext("emails", "Hi %{email},", email: @user.email) %>
 | 
			
		||||
 | 
			
		||||
<%= dgettext("emails", "You can reset your password by visiting the URL below:") %>
 | 
			
		||||
 | 
			
		||||
<%= @url %>
 | 
			
		||||
 | 
			
		||||
<%= dgettext("emails",
 | 
			
		||||
  "If you didn't request this change from %{url}, please ignore this.",
 | 
			
		||||
  url: Routes.live_url(Endpoint, HomeLive)) %>
 | 
			
		||||
							
								
								
									
										20
									
								
								lib/cannery_web/templates/email/update_email.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								lib/cannery_web/templates/email/update_email.html.heex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,20 @@
 | 
			
		||||
<div style="display: flex; flex-direction: column; justify-content: center; align-items: center;">
 | 
			
		||||
  <span style="margin-bottom: 0.5em; font-size: 1.5em;">
 | 
			
		||||
    <%= dgettext("emails", "Hi %{email},", email: @user.email) %>
 | 
			
		||||
  </span>
 | 
			
		||||
 | 
			
		||||
  <br />
 | 
			
		||||
 | 
			
		||||
  <%= dgettext("emails", "You can change your email by visiting the URL below:") %>
 | 
			
		||||
 | 
			
		||||
  <br />
 | 
			
		||||
 | 
			
		||||
  <a style="margin: 1em; color: rgb(31, 31, 31);" href={@url}><%= @url %></a>
 | 
			
		||||
 | 
			
		||||
  <br />
 | 
			
		||||
 | 
			
		||||
  <%= dgettext(
 | 
			
		||||
    "emails",
 | 
			
		||||
    "If you didn't request this change from Cannery, please ignore this."
 | 
			
		||||
  ) %>
 | 
			
		||||
</div>
 | 
			
		||||
							
								
								
									
										10
									
								
								lib/cannery_web/templates/email/update_email.txt.eex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								lib/cannery_web/templates/email/update_email.txt.eex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,10 @@
 | 
			
		||||
 | 
			
		||||
<%= dgettext("emails", "Hi %{email},", email: @user.email) %>
 | 
			
		||||
 | 
			
		||||
<%= dgettext("emails", "You can change your email by visiting the URL below:") %>
 | 
			
		||||
 | 
			
		||||
<%= @url %>
 | 
			
		||||
 | 
			
		||||
<%= dgettext("emails",
 | 
			
		||||
  "If you didn't request this change from %{url}, please ignore this.",
 | 
			
		||||
  url: Routes.live_url(Endpoint, HomeLive)) %>
 | 
			
		||||
							
								
								
									
										34
									
								
								lib/cannery_web/templates/error/error.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								lib/cannery_web/templates/error/error.html.heex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,34 @@
 | 
			
		||||
<!DOCTYPE html>
 | 
			
		||||
<html lang="en" class="m-0 p-0 w-full h-full">
 | 
			
		||||
  <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" />
 | 
			
		||||
    <title>
 | 
			
		||||
      <%= dgettext("errors", "Error") %> | <%= gettext("Cannery") %>
 | 
			
		||||
    </title>
 | 
			
		||||
    <link rel="stylesheet" href="/css/app.css" />
 | 
			
		||||
    <script defer type="text/javascript" src="/js/app.js">
 | 
			
		||||
    </script>
 | 
			
		||||
  </head>
 | 
			
		||||
  <body class="pb-8 m-0 p-0 w-full h-full">
 | 
			
		||||
    <header>
 | 
			
		||||
      <CanneryWeb.Components.Topbar.topbar current_user={assigns[:current_user]}>
 | 
			
		||||
      </CanneryWeb.Components.Topbar.topbar>
 | 
			
		||||
    </header>
 | 
			
		||||
 | 
			
		||||
    <div class="pb-8 w-full flex flex-col justify-center items-center text-center">
 | 
			
		||||
      <div class="p-8 sm:p-16 w-full flex flex-col justify-center items-center space-y-4 max-w-3xl">
 | 
			
		||||
        <h1 class="title text-primary-600 text-3xl">
 | 
			
		||||
          <%= @error_string %>
 | 
			
		||||
        </h1>
 | 
			
		||||
 | 
			
		||||
        <hr class="w-full hr" />
 | 
			
		||||
 | 
			
		||||
        <a href={Routes.live_path(Endpoint, HomeLive)} class="link title text-primary-600 text-lg">
 | 
			
		||||
          <%= dgettext("errors", "Go back home") %>
 | 
			
		||||
        </a>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </body>
 | 
			
		||||
</html>
 | 
			
		||||
							
								
								
									
										19
									
								
								lib/cannery_web/templates/layout/app.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								lib/cannery_web/templates/layout/app.html.heex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,19 @@
 | 
			
		||||
<main role="main" class="min-h-full min-w-full">
 | 
			
		||||
  <header>
 | 
			
		||||
    <CanneryWeb.Components.Topbar.topbar current_user={assigns[:current_user]}>
 | 
			
		||||
    </CanneryWeb.Components.Topbar.topbar>
 | 
			
		||||
 | 
			
		||||
    <div class="mx-8 my-2 flex flex-col space-y-4 text-center">
 | 
			
		||||
      <p :if={get_flash(@conn, :info)} class="alert alert-info" role="alert">
 | 
			
		||||
        <%= get_flash(@conn, :info) %>
 | 
			
		||||
      </p>
 | 
			
		||||
      <p :if={get_flash(@conn, :error)} class="alert alert-danger" role="alert">
 | 
			
		||||
        <%= get_flash(@conn, :error) %>
 | 
			
		||||
      </p>
 | 
			
		||||
    </div>
 | 
			
		||||
  </header>
 | 
			
		||||
 | 
			
		||||
  <div class="mx-4 sm:mx-8 md:mx-16">
 | 
			
		||||
    <%= @inner_content %>
 | 
			
		||||
  </div>
 | 
			
		||||
</main>
 | 
			
		||||
							
								
								
									
										19
									
								
								lib/cannery_web/templates/layout/email.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								lib/cannery_web/templates/layout/email.html.heex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,19 @@
 | 
			
		||||
<html>
 | 
			
		||||
  <head>
 | 
			
		||||
    <title>
 | 
			
		||||
      <%= @email.subject %>
 | 
			
		||||
    </title>
 | 
			
		||||
  </head>
 | 
			
		||||
  <body style="padding: 2em; color: rgb(31, 31, 31); background-color: rgb(220, 220, 228); font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif; text-align: center;">
 | 
			
		||||
    <%= @inner_content %>
 | 
			
		||||
 | 
			
		||||
    <hr style="margin: 2em auto; border-width: 1px; border-color: rgb(212, 212, 216); width: 100%; max-width: 42rem;" />
 | 
			
		||||
 | 
			
		||||
    <a style="color: rgb(31, 31, 31);" href={Routes.live_url(Endpoint, HomeLive)}>
 | 
			
		||||
      <%= dgettext(
 | 
			
		||||
        "emails",
 | 
			
		||||
        "This email was sent from Cannery, the self-hosted firearm tracker website."
 | 
			
		||||
      ) %>
 | 
			
		||||
    </a>
 | 
			
		||||
  </body>
 | 
			
		||||
</html>
 | 
			
		||||
							
								
								
									
										11
									
								
								lib/cannery_web/templates/layout/email.txt.eex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								lib/cannery_web/templates/layout/email.txt.eex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,11 @@
 | 
			
		||||
<%= @email.subject %>
 | 
			
		||||
 | 
			
		||||
====================
 | 
			
		||||
 | 
			
		||||
<%= @inner_content %>
 | 
			
		||||
 | 
			
		||||
=====================
 | 
			
		||||
 | 
			
		||||
<%= dgettext("emails",
 | 
			
		||||
  "This email was sent from Cannery at %{url}, the self-hosted firearm tracker website.",
 | 
			
		||||
  url: Routes.live_url(Endpoint, HomeLive)) %>
 | 
			
		||||
							
								
								
									
										1
									
								
								lib/cannery_web/templates/layout/empty.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								lib/cannery_web/templates/layout/empty.html.heex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
			
		||||
<%= @inner_content %>
 | 
			
		||||
							
								
								
									
										46
									
								
								lib/cannery_web/templates/layout/live.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								lib/cannery_web/templates/layout/live.html.heex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,46 @@
 | 
			
		||||
<main class="pb-8 min-w-full">
 | 
			
		||||
  <header>
 | 
			
		||||
    <CanneryWeb.Components.Topbar.topbar current_user={assigns[:current_user]}>
 | 
			
		||||
    </CanneryWeb.Components.Topbar.topbar>
 | 
			
		||||
 | 
			
		||||
    <div class="mx-8 my-2 flex flex-col space-y-4 text-center">
 | 
			
		||||
      <p
 | 
			
		||||
        :if={@flash && @flash |> Map.has_key?("info")}
 | 
			
		||||
        class="alert alert-info"
 | 
			
		||||
        role="alert"
 | 
			
		||||
        phx-click="lv:clear-flash"
 | 
			
		||||
        phx-value-key="info"
 | 
			
		||||
      >
 | 
			
		||||
        <%= live_flash(@flash, "info") %>
 | 
			
		||||
      </p>
 | 
			
		||||
 | 
			
		||||
      <p
 | 
			
		||||
        :if={@flash && @flash |> Map.has_key?("error")}
 | 
			
		||||
        class="alert alert-danger"
 | 
			
		||||
        role="alert"
 | 
			
		||||
        phx-click="lv:clear-flash"
 | 
			
		||||
        phx-value-key="error"
 | 
			
		||||
      >
 | 
			
		||||
        <%= live_flash(@flash, "error") %>
 | 
			
		||||
      </p>
 | 
			
		||||
    </div>
 | 
			
		||||
  </header>
 | 
			
		||||
 | 
			
		||||
  <div class="mx-4 sm:mx-8 md:mx-16">
 | 
			
		||||
    <%= @inner_content %>
 | 
			
		||||
  </div>
 | 
			
		||||
</main>
 | 
			
		||||
 | 
			
		||||
<div
 | 
			
		||||
  id="disconnect"
 | 
			
		||||
  class="z-50 fixed opacity-0 bottom-12 right-12 px-8 py-4 w-max h-max
 | 
			
		||||
  border border-primary-200 shadow-lg rounded-lg bg-white
 | 
			
		||||
  flex justify-center items-center space-x-4
 | 
			
		||||
  transition-opacity ease-in-out duration-500 delay-[2000ms]"
 | 
			
		||||
>
 | 
			
		||||
  <i class="fas fa-fade text-md fa-satellite-dish"></i>
 | 
			
		||||
 | 
			
		||||
  <h1 class="title text-md title-primary-500">
 | 
			
		||||
    <%= gettext("Reconnecting...") %>
 | 
			
		||||
  </h1>
 | 
			
		||||
</div>
 | 
			
		||||
							
								
								
									
										24
									
								
								lib/cannery_web/templates/layout/root.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								lib/cannery_web/templates/layout/root.html.heex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,24 @@
 | 
			
		||||
<!DOCTYPE html>
 | 
			
		||||
<html lang="en" class="m-0 p-0 w-full h-full">
 | 
			
		||||
  <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 suffix={" | #{gettext("Cannery")}"}>
 | 
			
		||||
      <%= assigns[:page_title] || gettext("Cannery") %>
 | 
			
		||||
    </.live_title>
 | 
			
		||||
    <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 class="m-0 p-0 w-full h-full subpixel-antialiased">
 | 
			
		||||
    <%= @inner_content %>
 | 
			
		||||
  </body>
 | 
			
		||||
</html>
 | 
			
		||||
							
								
								
									
										34
									
								
								lib/cannery_web/templates/user_confirmation/new.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								lib/cannery_web/templates/user_confirmation/new.html.heex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,34 @@
 | 
			
		||||
<div class="mx-auto pb-8 max-w-2xl flex flex-col justify-center items-center space-y-4">
 | 
			
		||||
  <h1 class="title text-primary-600 text-xl">
 | 
			
		||||
    <%= dgettext("actions", "Resend confirmation instructions") %>
 | 
			
		||||
  </h1>
 | 
			
		||||
 | 
			
		||||
  <.form
 | 
			
		||||
    :let={f}
 | 
			
		||||
    for={:user}
 | 
			
		||||
    action={Routes.user_confirmation_path(@conn, :create)}
 | 
			
		||||
    class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center"
 | 
			
		||||
  >
 | 
			
		||||
    <%= label(f, :email, gettext("Email"), class: "title text-lg text-primary-600") %>
 | 
			
		||||
    <%= email_input(f, :email, required: true, class: "input input-primary col-span-2") %>
 | 
			
		||||
 | 
			
		||||
    <%= submit(dgettext("actions", "Resend confirmation instructions"),
 | 
			
		||||
      class: "mx-auto btn btn-primary col-span-3"
 | 
			
		||||
    ) %>
 | 
			
		||||
  </.form>
 | 
			
		||||
 | 
			
		||||
  <hr class="hr" />
 | 
			
		||||
 | 
			
		||||
  <div class="flex flex-row justify-center items-center space-x-4">
 | 
			
		||||
    <.link
 | 
			
		||||
      :if={Accounts.allow_registration?()}
 | 
			
		||||
      href={Routes.user_registration_path(@conn, :new)}
 | 
			
		||||
      class="btn btn-primary"
 | 
			
		||||
    >
 | 
			
		||||
      <%= dgettext("actions", "Register") %>
 | 
			
		||||
    </.link>
 | 
			
		||||
    <.link href={Routes.user_session_path(@conn, :new)} class="btn btn-primary">
 | 
			
		||||
      <%= dgettext("actions", "Log in") %>
 | 
			
		||||
    </.link>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
							
								
								
									
										50
									
								
								lib/cannery_web/templates/user_registration/new.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								lib/cannery_web/templates/user_registration/new.html.heex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,50 @@
 | 
			
		||||
<div class="mx-auto pb-8 max-w-2xl flex flex-col justify-center items-center space-y-4">
 | 
			
		||||
  <h1 class="title text-primary-600 text-xl">
 | 
			
		||||
    <%= dgettext("actions", "Register") %>
 | 
			
		||||
  </h1>
 | 
			
		||||
 | 
			
		||||
  <.form
 | 
			
		||||
    :let={f}
 | 
			
		||||
    for={@changeset}
 | 
			
		||||
    action={Routes.user_registration_path(@conn, :create)}
 | 
			
		||||
    class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center"
 | 
			
		||||
  >
 | 
			
		||||
    <p :if={@changeset.action && not @changeset.valid?()} class="alert alert-danger col-span-3">
 | 
			
		||||
      <%= dgettext("errors", "Oops, something went wrong! Please check the errors below.") %>
 | 
			
		||||
    </p>
 | 
			
		||||
 | 
			
		||||
    <%= if @invite_token do %>
 | 
			
		||||
      <%= hidden_input(f, :invite_token, value: @invite_token) %>
 | 
			
		||||
    <% end %>
 | 
			
		||||
 | 
			
		||||
    <%= label(f, :email, gettext("Email"), class: "title text-lg text-primary-600") %>
 | 
			
		||||
    <%= email_input(f, :email, required: true, class: "input input-primary col-span-2") %>
 | 
			
		||||
    <%= error_tag(f, :email, "col-span-3") %>
 | 
			
		||||
 | 
			
		||||
    <%= label(f, :password, gettext("Password"), class: "title text-lg text-primary-600") %>
 | 
			
		||||
    <%= password_input(f, :password, required: true, class: "input input-primary col-span-2") %>
 | 
			
		||||
    <%= error_tag(f, :password, "col-span-3") %>
 | 
			
		||||
 | 
			
		||||
    <%= label(f, :locale, gettext("Language"), class: "title text-lg text-primary-600") %>
 | 
			
		||||
    <%= select(
 | 
			
		||||
      f,
 | 
			
		||||
      :locale,
 | 
			
		||||
      [{gettext("English"), "en_US"}],
 | 
			
		||||
      class: "input input-primary col-span-2"
 | 
			
		||||
    ) %>
 | 
			
		||||
    <%= error_tag(f, :locale) %>
 | 
			
		||||
 | 
			
		||||
    <%= submit(dgettext("actions", "Register"), class: "mx-auto btn btn-primary col-span-3") %>
 | 
			
		||||
  </.form>
 | 
			
		||||
 | 
			
		||||
  <hr class="hr" />
 | 
			
		||||
 | 
			
		||||
  <div class="flex flex-row justify-center items-center space-x-4">
 | 
			
		||||
    <.link href={Routes.user_session_path(@conn, :new)} class="btn btn-primary">
 | 
			
		||||
      <%= dgettext("actions", "Log in") %>
 | 
			
		||||
    </.link>
 | 
			
		||||
    <.link href={Routes.user_reset_password_path(@conn, :new)} class="btn btn-primary">
 | 
			
		||||
      <%= dgettext("actions", "Forgot your password?") %>
 | 
			
		||||
    </.link>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
							
								
								
									
										48
									
								
								lib/cannery_web/templates/user_reset_password/edit.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								lib/cannery_web/templates/user_reset_password/edit.html.heex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,48 @@
 | 
			
		||||
<div class="mx-auto pb-8 max-w-2xl flex flex-col justify-center items-center space-y-4">
 | 
			
		||||
  <h1 class="title text-primary-600 text-xl">
 | 
			
		||||
    <%= dgettext("actions", "Reset password") %>
 | 
			
		||||
  </h1>
 | 
			
		||||
 | 
			
		||||
  <.form
 | 
			
		||||
    :let={f}
 | 
			
		||||
    for={@changeset}
 | 
			
		||||
    action={Routes.user_reset_password_path(@conn, :update, @token)}
 | 
			
		||||
    class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center"
 | 
			
		||||
  >
 | 
			
		||||
    <p :if={@changeset.action && not @changeset.valid?()} class="alert alert-danger col-span-3">
 | 
			
		||||
      <%= dgettext("errors", "Oops, something went wrong! Please check the errors below.") %>
 | 
			
		||||
    </p>
 | 
			
		||||
 | 
			
		||||
    <%= label(f, :password, gettext("New password"), class: "title text-lg text-primary-600") %>
 | 
			
		||||
    <%= password_input(f, :password, required: true, class: "input input-primary col-span-2") %>
 | 
			
		||||
    <%= error_tag(f, :password, "col-span-3") %>
 | 
			
		||||
 | 
			
		||||
    <%= label(f, :password_confirmation, gettext("Confirm new password"),
 | 
			
		||||
      class: "title text-lg text-primary-600"
 | 
			
		||||
    ) %>
 | 
			
		||||
    <%= password_input(f, :password_confirmation,
 | 
			
		||||
      required: true,
 | 
			
		||||
      class: "input input-primary col-span-2"
 | 
			
		||||
    ) %>
 | 
			
		||||
    <%= error_tag(f, :password_confirmation, "col-span-3") %>
 | 
			
		||||
 | 
			
		||||
    <%= submit(dgettext("actions", "Reset password"),
 | 
			
		||||
      class: "mx-auto btn btn-primary col-span-3"
 | 
			
		||||
    ) %>
 | 
			
		||||
  </.form>
 | 
			
		||||
 | 
			
		||||
  <hr class="hr" />
 | 
			
		||||
 | 
			
		||||
  <div class="flex flex-row justify-center items-center space-x-4">
 | 
			
		||||
    <.link
 | 
			
		||||
      :if={Accounts.allow_registration?()}
 | 
			
		||||
      href={Routes.user_registration_path(@conn, :new)}
 | 
			
		||||
      class="btn btn-primary"
 | 
			
		||||
    >
 | 
			
		||||
      <%= dgettext("actions", "Register") %>
 | 
			
		||||
    </.link>
 | 
			
		||||
    <.link href={Routes.user_session_path(@conn, :new)} class="btn btn-primary">
 | 
			
		||||
      <%= dgettext("actions", "Log in") %>
 | 
			
		||||
    </.link>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
							
								
								
									
										34
									
								
								lib/cannery_web/templates/user_reset_password/new.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								lib/cannery_web/templates/user_reset_password/new.html.heex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,34 @@
 | 
			
		||||
<div class="mx-auto pb-8 max-w-2xl flex flex-col justify-center items-center space-y-4">
 | 
			
		||||
  <h1 class="title text-primary-600 text-xl">
 | 
			
		||||
    <%= dgettext("actions", "Forgot your password?") %>
 | 
			
		||||
  </h1>
 | 
			
		||||
 | 
			
		||||
  <.form
 | 
			
		||||
    :let={f}
 | 
			
		||||
    for={:user}
 | 
			
		||||
    action={Routes.user_reset_password_path(@conn, :create)}
 | 
			
		||||
    class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center"
 | 
			
		||||
  >
 | 
			
		||||
    <%= label(f, :email, gettext("Email"), class: "title text-lg text-primary-600") %>
 | 
			
		||||
    <%= email_input(f, :email, required: true, class: "input input-primary col-span-2") %>
 | 
			
		||||
 | 
			
		||||
    <%= submit(dgettext("actions", "Send instructions to reset password"),
 | 
			
		||||
      class: "mx-auto btn btn-primary col-span-3"
 | 
			
		||||
    ) %>
 | 
			
		||||
  </.form>
 | 
			
		||||
 | 
			
		||||
  <hr class="hr" />
 | 
			
		||||
 | 
			
		||||
  <div class="flex flex-row justify-center items-center space-x-4">
 | 
			
		||||
    <.link
 | 
			
		||||
      :if={Accounts.allow_registration?()}
 | 
			
		||||
      href={Routes.user_registration_path(@conn, :new)}
 | 
			
		||||
      class="btn btn-primary"
 | 
			
		||||
    >
 | 
			
		||||
      <%= dgettext("actions", "Register") %>
 | 
			
		||||
    </.link>
 | 
			
		||||
    <.link href={Routes.user_session_path(@conn, :new)} class="btn btn-primary">
 | 
			
		||||
      <%= dgettext("actions", "Log in") %>
 | 
			
		||||
    </.link>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
							
								
								
									
										45
									
								
								lib/cannery_web/templates/user_session/new.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								lib/cannery_web/templates/user_session/new.html.heex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,45 @@
 | 
			
		||||
<div class="mx-auto pb-8 max-w-2xl flex flex-col justify-center items-center space-y-4">
 | 
			
		||||
  <h1 class="title text-primary-600 text-xl">
 | 
			
		||||
    <%= dgettext("actions", "Log in") %>
 | 
			
		||||
  </h1>
 | 
			
		||||
 | 
			
		||||
  <.form
 | 
			
		||||
    :let={f}
 | 
			
		||||
    for={@conn}
 | 
			
		||||
    action={Routes.user_session_path(@conn, :create)}
 | 
			
		||||
    as="user"
 | 
			
		||||
    class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center"
 | 
			
		||||
  >
 | 
			
		||||
    <p :if={@error_message} class="alert alert-danger col-span-3">
 | 
			
		||||
      <%= @error_message %>
 | 
			
		||||
    </p>
 | 
			
		||||
 | 
			
		||||
    <%= label(f, :email, gettext("Email"), class: "title text-lg text-primary-600") %>
 | 
			
		||||
    <%= email_input(f, :email, required: true, class: "input input-primary col-span-2") %>
 | 
			
		||||
 | 
			
		||||
    <%= label(f, :password, gettext("Password"), class: "title text-lg text-primary-600") %>
 | 
			
		||||
    <%= password_input(f, :password, required: true, class: "input input-primary col-span-2") %>
 | 
			
		||||
 | 
			
		||||
    <%= label(f, :remember_me, gettext("Keep me logged in for 60 days"),
 | 
			
		||||
      class: "title text-lg text-primary-600"
 | 
			
		||||
    ) %>
 | 
			
		||||
    <%= checkbox(f, :remember_me, class: "checkbox col-span-2") %>
 | 
			
		||||
 | 
			
		||||
    <%= submit(dgettext("actions", "Log in"), class: "mx-auto btn btn-primary col-span-3") %>
 | 
			
		||||
  </.form>
 | 
			
		||||
 | 
			
		||||
  <hr class="hr" />
 | 
			
		||||
 | 
			
		||||
  <div class="flex flex-row justify-center items-center space-x-4">
 | 
			
		||||
    <.link
 | 
			
		||||
      :if={Accounts.allow_registration?()}
 | 
			
		||||
      href={Routes.user_registration_path(@conn, :new)}
 | 
			
		||||
      class="btn btn-primary"
 | 
			
		||||
    >
 | 
			
		||||
      <%= dgettext("actions", "Register") %>
 | 
			
		||||
    </.link>
 | 
			
		||||
    <.link href={Routes.user_reset_password_path(@conn, :new)} class="btn btn-primary">
 | 
			
		||||
      <%= dgettext("actions", "Forgot your password?") %>
 | 
			
		||||
    </.link>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
							
								
								
									
										144
									
								
								lib/cannery_web/templates/user_settings/edit.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										144
									
								
								lib/cannery_web/templates/user_settings/edit.html.heex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,144 @@
 | 
			
		||||
<div class="mx-auto pb-8 max-w-2xl flex flex-col justify-center items-center text-right space-y-4">
 | 
			
		||||
  <h1 class="pb-4 title text-primary-600 text-2xl text-center">
 | 
			
		||||
    <%= gettext("Settings") %>
 | 
			
		||||
  </h1>
 | 
			
		||||
 | 
			
		||||
  <hr class="hr" />
 | 
			
		||||
 | 
			
		||||
  <.form
 | 
			
		||||
    :let={f}
 | 
			
		||||
    for={@email_changeset}
 | 
			
		||||
    action={Routes.user_settings_path(@conn, :update)}
 | 
			
		||||
    class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center"
 | 
			
		||||
  >
 | 
			
		||||
    <h3 class="title text-primary-600 text-lg text-center col-span-3">
 | 
			
		||||
      <%= dgettext("actions", "Change email") %>
 | 
			
		||||
    </h3>
 | 
			
		||||
 | 
			
		||||
    <div
 | 
			
		||||
      :if={@email_changeset.action && not @email_changeset.valid?()}
 | 
			
		||||
      class="alert alert-danger col-span-3"
 | 
			
		||||
    >
 | 
			
		||||
      <%= dgettext("errors", "Oops, something went wrong! Please check the errors below.") %>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <%= hidden_input(f, :action, name: "action", value: "update_email") %>
 | 
			
		||||
 | 
			
		||||
    <%= label(f, :email, gettext("Email"), class: "title text-lg text-primary-600") %>
 | 
			
		||||
    <%= email_input(f, :email, required: true, class: "mx-2 my-1 input input-primary col-span-2") %>
 | 
			
		||||
    <%= error_tag(f, :email, "col-span-3") %>
 | 
			
		||||
 | 
			
		||||
    <%= label(f, :current_password, gettext("Current password"),
 | 
			
		||||
      for: "current_password_for_email",
 | 
			
		||||
      class: "mx-2 my-1 title text-lg text-primary-600"
 | 
			
		||||
    ) %>
 | 
			
		||||
    <%= password_input(f, :current_password,
 | 
			
		||||
      required: true,
 | 
			
		||||
      name: "current_password",
 | 
			
		||||
      id: "current_password_for_email",
 | 
			
		||||
      class: "mx-2 my-1 input input-primary col-span-2"
 | 
			
		||||
    ) %>
 | 
			
		||||
    <%= error_tag(f, :current_password, "col-span-3") %>
 | 
			
		||||
 | 
			
		||||
    <%= submit(dgettext("actions", "Change email"),
 | 
			
		||||
      class: "mx-auto btn btn-primary col-span-3"
 | 
			
		||||
    ) %>
 | 
			
		||||
  </.form>
 | 
			
		||||
 | 
			
		||||
  <hr class="hr" />
 | 
			
		||||
 | 
			
		||||
  <.form
 | 
			
		||||
    :let={f}
 | 
			
		||||
    for={@password_changeset}
 | 
			
		||||
    action={Routes.user_settings_path(@conn, :update)}
 | 
			
		||||
    class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center"
 | 
			
		||||
  >
 | 
			
		||||
    <h3 class="title text-primary-600 text-lg text-center col-span-3">
 | 
			
		||||
      <%= dgettext("actions", "Change password") %>
 | 
			
		||||
    </h3>
 | 
			
		||||
 | 
			
		||||
    <div
 | 
			
		||||
      :if={@password_changeset.action && not @password_changeset.valid?()}
 | 
			
		||||
      class="alert alert-danger col-span-3"
 | 
			
		||||
    >
 | 
			
		||||
      <%= dgettext("errors", "Oops, something went wrong! Please check the errors below.") %>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <%= hidden_input(f, :action, name: "action", value: "update_password") %>
 | 
			
		||||
 | 
			
		||||
    <%= label(f, :password, gettext("New password"), class: "title text-lg text-primary-600") %>
 | 
			
		||||
    <%= password_input(f, :password,
 | 
			
		||||
      required: true,
 | 
			
		||||
      class: "mx-2 my-1 input input-primary col-span-2"
 | 
			
		||||
    ) %>
 | 
			
		||||
    <%= error_tag(f, :password, "col-span-3") %>
 | 
			
		||||
 | 
			
		||||
    <%= label(f, :password_confirmation, gettext("Confirm new password"),
 | 
			
		||||
      class: "title text-lg text-primary-600"
 | 
			
		||||
    ) %>
 | 
			
		||||
    <%= password_input(f, :password_confirmation,
 | 
			
		||||
      required: true,
 | 
			
		||||
      class: "mx-2 my-1 input input-primary col-span-2"
 | 
			
		||||
    ) %>
 | 
			
		||||
    <%= error_tag(f, :password_confirmation, "col-span-3") %>
 | 
			
		||||
 | 
			
		||||
    <%= label(f, :current_password, gettext("Current password"),
 | 
			
		||||
      for: "current_password_for_password",
 | 
			
		||||
      class: "title text-lg text-primary-600"
 | 
			
		||||
    ) %>
 | 
			
		||||
    <%= password_input(f, :current_password,
 | 
			
		||||
      required: true,
 | 
			
		||||
      name: "current_password",
 | 
			
		||||
      id: "current_password_for_password",
 | 
			
		||||
      class: "mx-2 my-1 input input-primary col-span-2"
 | 
			
		||||
    ) %>
 | 
			
		||||
    <%= error_tag(f, :current_password, "col-span-3") %>
 | 
			
		||||
 | 
			
		||||
    <%= submit(dgettext("actions", "Change password"),
 | 
			
		||||
      class: "mx-auto btn btn-primary col-span-3"
 | 
			
		||||
    ) %>
 | 
			
		||||
  </.form>
 | 
			
		||||
 | 
			
		||||
  <hr class="hr" />
 | 
			
		||||
 | 
			
		||||
  <.form
 | 
			
		||||
    :let={f}
 | 
			
		||||
    for={@locale_changeset}
 | 
			
		||||
    action={Routes.user_settings_path(@conn, :update)}
 | 
			
		||||
    class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center"
 | 
			
		||||
  >
 | 
			
		||||
    <h3 class="title text-primary-600 text-lg text-center col-span-3">
 | 
			
		||||
      <%= dgettext("actions", "Change Language") %>
 | 
			
		||||
    </h3>
 | 
			
		||||
 | 
			
		||||
    <div
 | 
			
		||||
      :if={@locale_changeset.action && not @locale_changeset.valid?}
 | 
			
		||||
      class="alert alert-danger col-span-3"
 | 
			
		||||
    >
 | 
			
		||||
      <%= dgettext("errors", "Oops, something went wrong! Please check the errors below.") %>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <%= hidden_input(f, :action, name: "action", value: "update_locale") %>
 | 
			
		||||
 | 
			
		||||
    <%= select(f, :locale, [{gettext("English"), "en_US"}, {"Spanish", "es"}],
 | 
			
		||||
      class: "mx-2 my-1 min-w-md input input-primary col-span-3"
 | 
			
		||||
    ) %>
 | 
			
		||||
    <%= error_tag(f, :locale, "col-span-3") %>
 | 
			
		||||
 | 
			
		||||
    <%= submit(dgettext("actions", "Change language"),
 | 
			
		||||
      class: "whitespace-nowrap mx-auto btn btn-primary col-span-3",
 | 
			
		||||
      data: [qa: dgettext("prompts", "Are you sure you want to change your language?")]
 | 
			
		||||
    ) %>
 | 
			
		||||
  </.form>
 | 
			
		||||
 | 
			
		||||
  <hr class="hr" />
 | 
			
		||||
 | 
			
		||||
  <.link
 | 
			
		||||
    href={Routes.user_settings_path(@conn, :delete, @current_user)}
 | 
			
		||||
    method={:delete}
 | 
			
		||||
    class="btn btn-alert"
 | 
			
		||||
    data-confirm={dgettext("prompts", "Are you sure you want to delete your account?")}
 | 
			
		||||
  >
 | 
			
		||||
    <%= dgettext("actions", "Delete User") %>
 | 
			
		||||
  </.link>
 | 
			
		||||
</div>
 | 
			
		||||
							
								
								
									
										7
									
								
								lib/cannery_web/views/email_view.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								lib/cannery_web/views/email_view.ex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,7 @@
 | 
			
		||||
defmodule CanneryWeb.EmailView do
 | 
			
		||||
  @moduledoc """
 | 
			
		||||
  A view for email-related helper functions
 | 
			
		||||
  """
 | 
			
		||||
  use CanneryWeb, :view
 | 
			
		||||
  alias CanneryWeb.{Endpoint, HomeLive}
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										84
									
								
								lib/cannery_web/views/error_helpers.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								lib/cannery_web/views/error_helpers.ex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,84 @@
 | 
			
		||||
defmodule CanneryWeb.ErrorHelpers do
 | 
			
		||||
  @moduledoc """
 | 
			
		||||
  Conveniences for translating and building error messages.
 | 
			
		||||
  """
 | 
			
		||||
 | 
			
		||||
  use Phoenix.HTML
 | 
			
		||||
  import Phoenix.Component
 | 
			
		||||
  alias Ecto.Changeset
 | 
			
		||||
  alias Phoenix.{HTML.Form, LiveView.Rendered}
 | 
			
		||||
 | 
			
		||||
  @doc """
 | 
			
		||||
  Generates tag for inlined form input errors.
 | 
			
		||||
  """
 | 
			
		||||
  @spec error_tag(Form.t(), Form.field()) :: Rendered.t()
 | 
			
		||||
  @spec error_tag(Form.t(), Form.field(), String.t()) :: Rendered.t()
 | 
			
		||||
  def error_tag(form, field, extra_class \\ "") do
 | 
			
		||||
    assigns = %{extra_class: extra_class, form: form, field: field}
 | 
			
		||||
 | 
			
		||||
    ~H"""
 | 
			
		||||
    <span
 | 
			
		||||
      :for={error <- Keyword.get_values(@form.errors, @field)}
 | 
			
		||||
      class={["invalid-feedback", @extra_class]}
 | 
			
		||||
      phx-feedback-for={input_name(@form, @field)}
 | 
			
		||||
    >
 | 
			
		||||
      <%= translate_error(error) %>
 | 
			
		||||
    </span>
 | 
			
		||||
    """
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  @doc """
 | 
			
		||||
  Translates an error message using gettext.
 | 
			
		||||
  """
 | 
			
		||||
  @spec translate_error({String.t(), keyword() | map()}) :: String.t()
 | 
			
		||||
  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(CanneryWeb.Gettext, "errors", msg, msg, count, opts)
 | 
			
		||||
    else
 | 
			
		||||
      Gettext.dgettext(CanneryWeb.Gettext, "errors", msg, opts)
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  @doc """
 | 
			
		||||
  Displays all errors from a changeset, or just for a single key
 | 
			
		||||
  """
 | 
			
		||||
  @spec changeset_errors(Changeset.t()) :: String.t()
 | 
			
		||||
  @spec changeset_errors(Changeset.t(), key :: atom()) :: [String.t()] | nil
 | 
			
		||||
  def changeset_errors(changeset) do
 | 
			
		||||
    changeset
 | 
			
		||||
    |> changeset_error_map()
 | 
			
		||||
    |> Enum.map_join(". ", fn {key, errors} ->
 | 
			
		||||
      "#{key |> humanize()}: #{errors |> Enum.join(", ")}"
 | 
			
		||||
    end)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def changeset_errors(changeset, key) do
 | 
			
		||||
    changeset |> changeset_error_map() |> Map.get(key)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  @doc """
 | 
			
		||||
  Displays all errors from a changeset in a key value map
 | 
			
		||||
  """
 | 
			
		||||
  @spec changeset_error_map(Changeset.t()) :: %{atom() => [String.t()]}
 | 
			
		||||
  def changeset_error_map(changeset) do
 | 
			
		||||
    changeset
 | 
			
		||||
    |> Changeset.traverse_errors(fn error -> error |> translate_error() end)
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										15
									
								
								lib/cannery_web/views/error_view.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								lib/cannery_web/views/error_view.ex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,15 @@
 | 
			
		||||
defmodule CanneryWeb.ErrorView do
 | 
			
		||||
  use CanneryWeb, :view
 | 
			
		||||
  alias CanneryWeb.{Endpoint, HomeLive}
 | 
			
		||||
 | 
			
		||||
  def template_not_found(error_path, _assigns) do
 | 
			
		||||
    error_string =
 | 
			
		||||
      case error_path do
 | 
			
		||||
        "404.html" -> dgettext("errors", "Not found")
 | 
			
		||||
        "401.html" -> dgettext("errors", "Unauthorized")
 | 
			
		||||
        _other_path -> dgettext("errors", "Internal Server Error")
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
    render("error.html", %{error_string: error_string})
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										3
									
								
								lib/cannery_web/views/home_view.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								lib/cannery_web/views/home_view.ex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
			
		||||
defmodule CanneryWeb.PageView do
 | 
			
		||||
  use CanneryWeb, :view
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										16
									
								
								lib/cannery_web/views/layout_view.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								lib/cannery_web/views/layout_view.ex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,16 @@
 | 
			
		||||
defmodule CanneryWeb.LayoutView do
 | 
			
		||||
  use CanneryWeb, :view
 | 
			
		||||
  alias CanneryWeb.{Endpoint, HomeLive}
 | 
			
		||||
 | 
			
		||||
  # Phoenix LiveDashboard is available only in development by default,
 | 
			
		||||
  # so we instruct Elixir to not warn if the dashboard route is missing.
 | 
			
		||||
  @compile {:no_warn_undefined, {Routes, :live_dashboard_path, 2}}
 | 
			
		||||
 | 
			
		||||
  def get_title(%{assigns: %{title: title}}) when title not in [nil, ""] do
 | 
			
		||||
    gettext("Cannery | %{title}", title: title)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def get_title(_conn) do
 | 
			
		||||
    gettext("Cannery")
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										4
									
								
								lib/cannery_web/views/user_confirmation_view.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								lib/cannery_web/views/user_confirmation_view.ex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,4 @@
 | 
			
		||||
defmodule CanneryWeb.UserConfirmationView do
 | 
			
		||||
  use CanneryWeb, :view
 | 
			
		||||
  alias Cannery.Accounts
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										3
									
								
								lib/cannery_web/views/user_registration_view.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								lib/cannery_web/views/user_registration_view.ex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
			
		||||
defmodule CanneryWeb.UserRegistrationView do
 | 
			
		||||
  use CanneryWeb, :view
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										4
									
								
								lib/cannery_web/views/user_reset_password_view.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								lib/cannery_web/views/user_reset_password_view.ex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,4 @@
 | 
			
		||||
defmodule CanneryWeb.UserResetPasswordView do
 | 
			
		||||
  use CanneryWeb, :view
 | 
			
		||||
  alias Cannery.Accounts
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										4
									
								
								lib/cannery_web/views/user_session_view.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								lib/cannery_web/views/user_session_view.ex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,4 @@
 | 
			
		||||
defmodule CanneryWeb.UserSessionView do
 | 
			
		||||
  use CanneryWeb, :view
 | 
			
		||||
  alias Cannery.Accounts
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										3
									
								
								lib/cannery_web/views/user_settings_view.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								lib/cannery_web/views/user_settings_view.ex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
			
		||||
defmodule CanneryWeb.UserSettingsView do
 | 
			
		||||
  use CanneryWeb, :view
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										96
									
								
								lib/cannery_web/views/view_helpers.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								lib/cannery_web/views/view_helpers.ex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,96 @@
 | 
			
		||||
defmodule CanneryWeb.ViewHelpers do
 | 
			
		||||
  @moduledoc """
 | 
			
		||||
  Contains common helpers that can be used in liveviews and regular views. These
 | 
			
		||||
  are automatically imported into any Phoenix View using `use CanneryWeb,
 | 
			
		||||
  :view`
 | 
			
		||||
  """
 | 
			
		||||
 | 
			
		||||
  use Phoenix.Component
 | 
			
		||||
 | 
			
		||||
  @doc """
 | 
			
		||||
  Phoenix.Component for a <time> element that renders the naivedatetime in the
 | 
			
		||||
  user's local timezone with Alpine.js
 | 
			
		||||
  """
 | 
			
		||||
 | 
			
		||||
  attr :datetime, :any, required: true, doc: "A `DateTime` struct or nil"
 | 
			
		||||
 | 
			
		||||
  def datetime(assigns) do
 | 
			
		||||
    ~H"""
 | 
			
		||||
    <time
 | 
			
		||||
      :if={@datetime}
 | 
			
		||||
      datetime={cast_datetime(@datetime)}
 | 
			
		||||
      x-data={"{
 | 
			
		||||
        datetime:
 | 
			
		||||
          Intl.DateTimeFormat([], {dateStyle: 'short', timeStyle: 'long'})
 | 
			
		||||
            .format(new Date(\"#{cast_datetime(@datetime)}\"))
 | 
			
		||||
      }"}
 | 
			
		||||
      x-text="datetime"
 | 
			
		||||
    >
 | 
			
		||||
      <%= cast_datetime(@datetime) %>
 | 
			
		||||
    </time>
 | 
			
		||||
    """
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  @spec cast_datetime(NaiveDateTime.t() | nil) :: String.t()
 | 
			
		||||
  defp cast_datetime(%NaiveDateTime{} = datetime) do
 | 
			
		||||
    datetime |> DateTime.from_naive!("Etc/UTC") |> DateTime.to_iso8601(:extended)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  defp cast_datetime(_datetime), do: ""
 | 
			
		||||
 | 
			
		||||
  @doc """
 | 
			
		||||
  Phoenix.Component for a <date> element that renders the Date in the user's
 | 
			
		||||
  local timezone with Alpine.js
 | 
			
		||||
  """
 | 
			
		||||
 | 
			
		||||
  attr :date, :any, required: true, doc: "A `Date` struct or nil"
 | 
			
		||||
 | 
			
		||||
  def date(assigns) do
 | 
			
		||||
    ~H"""
 | 
			
		||||
    <time
 | 
			
		||||
      :if={@date}
 | 
			
		||||
      datetime={@date |> Date.to_iso8601(:extended)}
 | 
			
		||||
      x-data={"{
 | 
			
		||||
        date:
 | 
			
		||||
          Intl.DateTimeFormat([], {timeZone: 'Etc/UTC', dateStyle: 'short'})
 | 
			
		||||
            .format(new Date(\"#{@date |> Date.to_iso8601(:extended)}\"))
 | 
			
		||||
      }"}
 | 
			
		||||
      x-text="date"
 | 
			
		||||
    >
 | 
			
		||||
      <%= @date |> Date.to_iso8601(:extended) %>
 | 
			
		||||
    </time>
 | 
			
		||||
    """
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  @doc """
 | 
			
		||||
  Displays content in a QR code as a base64 encoded PNG
 | 
			
		||||
  """
 | 
			
		||||
  @spec qr_code_image(String.t()) :: String.t()
 | 
			
		||||
  @spec qr_code_image(String.t(), width :: non_neg_integer()) :: String.t()
 | 
			
		||||
  def qr_code_image(content, width \\ 384) do
 | 
			
		||||
    img_data =
 | 
			
		||||
      content
 | 
			
		||||
      |> EQRCode.encode()
 | 
			
		||||
      |> EQRCode.png(width: width)
 | 
			
		||||
      |> Base.encode64()
 | 
			
		||||
 | 
			
		||||
    "data:image/png;base64," <> img_data
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  @doc """
 | 
			
		||||
  Creates a downloadable QR Code element
 | 
			
		||||
  """
 | 
			
		||||
 | 
			
		||||
  attr :content, :string, required: true
 | 
			
		||||
  attr :filename, :string, default: "qrcode", doc: "filename without .png extension"
 | 
			
		||||
  attr :image_class, :string, default: "w-64 h-max"
 | 
			
		||||
  attr :width, :integer, default: 384, doc: "width of png to generate"
 | 
			
		||||
 | 
			
		||||
  def qr_code(assigns) do
 | 
			
		||||
    ~H"""
 | 
			
		||||
    <a href={qr_code_image(@content)} download={@filename <> ".png"}>
 | 
			
		||||
      <img class={@image_class} alt={@filename} src={qr_code_image(@content)} />
 | 
			
		||||
    </a>
 | 
			
		||||
    """
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
		Reference in New Issue
	
	Block a user