towerops/docs/templates/liveview-index.md
Graham McIntie 1590f78bdc fix: input validation, SSRF, API hardening, and cookie security
- LIKE wildcard injection: sanitize %, _ in search queries (devices, sites, gaiia)
- Jason.decode! → Jason.decode with error handling for untrusted input
- inspect() leak: replace with generic error messages, log details server-side
- SSRF protection: URL validator blocks private IPs, localhost, non-HTTP schemes
- SSRF validation added to HTTP monitoring executor and integration credentials
- GraphQL complexity limits: always applied, not just in prod
- GraphQL introspection: also check GET query params, not just body
- Stripe webhook: explicit nil/empty checks for signature and body
- Cookie security: secure flag for session (prod), http_only+secure for remember_me
- Honeybadger API key: read from env var with fallback
- String.to_integer → Integer.parse with fallback for URL params
- String.to_atom → whitelist map for HTTP methods
- Gaiia webhook: remove secret_len and expected signature from log
- Admin API: add rate limiting pipeline
- to_atom_keys: per-key fallback instead of all-or-nothing rescue
2026-03-14 14:48:59 -05:00

8 KiB

LiveView Index Page Template

Copy-pasteable template for a new list/index page following TowerOps conventions.

Replace all __RESOURCE__ placeholders with your resource name (e.g., Schedule, Device). Replace __resource__ with the lowercase/snake_case form (e.g., schedule, device). Replace __resources__ with the plural form (e.g., schedules, devices). Replace __active_page__ with the sidebar page key (e.g., "schedules").


lib/towerops_web/live/__resource___live/index.ex

defmodule ToweropsWeb.__RESOURCE__Live.Index do
  @moduledoc false
  use ToweropsWeb, :live_view

  alias Towerops.__CONTEXT__

  @impl true
  def mount(_params, _session, socket) do
    organization = socket.assigns.current_scope.organization

    {:ok,
     socket
     |> assign(:page_title, t("__RESOURCES_TITLE__"))
     |> assign(:filter, "all")
     |> assign(:timezone, socket.assigns.current_scope.timezone)
     |> load___resources__(organization.id)}
  end

  @impl true
  def handle_params(params, _url, socket) do
    filter = Map.get(params, "filter", "all")

    {:noreply,
     socket
     |> assign(:filter, filter)
     |> load___resources__(socket.assigns.current_scope.organization.id)}
  end

  @impl true
  def handle_event("delete", %{"id" => id}, socket) do
    organization = socket.assigns.current_scope.organization
    __resource__ = __CONTEXT__.get___resource__!(id)

    case __CONTEXT__.delete___resource__(__resource__) do
      {:ok, _} ->
        {:noreply,
         socket
         |> put_flash(:info, t("__RESOURCE_TITLE__ deleted"))
         |> load___resources__(organization.id)}

      {:error, _changeset} ->
        {:noreply, put_flash(socket, :error, t("Unable to delete __resource__"))}
    end
  end

  defp load___resources__(socket, org_id) do
    filter_atom =
      case socket.assigns.filter do
        "active" -> :active
        "archived" -> :archived
        _ -> nil
      end

    opts = if filter_atom, do: [filter: filter_atom], else: []
    __resources__ = __CONTEXT__.list___resources__(org_id, opts)
    assign(socket, :__resources__, __resources__)
  end
end

lib/towerops_web/live/__resource___live/index.html.heex

<Layouts.authenticated
  flash={@flash}
  current_scope={@current_scope}
  active_page="__active_page__"
>
  <%!-- Page header --%>
  <div class="flex items-center justify-between mb-1">
    <h1 class="text-xl font-bold text-gray-900 dark:text-white">{t("__RESOURCES_TITLE__")}</h1>
    <.link
      navigate={~p"/__resources__/new"}
      class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500 transition-colors"
    >
      <.icon name="hero-plus" class="h-4 w-4" />
      {t("New __RESOURCE_TITLE__")}
    </.link>
  </div>
  <p class="text-sm text-gray-500 dark:text-gray-400 mb-4">
    {t("Description of what this page shows.")}
  </p>

  <%!-- Filter tabs --%>
  <div class="flex items-center gap-2 mb-4 flex-wrap">
    <.link
      patch={~p"/__resources__?filter=all"}
      class={[
        "inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
        @filter == "all" && "bg-gray-900 text-white dark:bg-white dark:text-gray-900",
        @filter != "all" &&
          "bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
      ]}
    >
      {t("All")}
    </.link>
    <.link
      patch={~p"/__resources__?filter=active"}
      class={[
        "inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
        @filter == "active" && "bg-gray-900 text-white dark:bg-white dark:text-gray-900",
        @filter != "active" &&
          "bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
      ]}
    >
      {t("Active")}
    </.link>
    <.link
      patch={~p"/__resources__?filter=archived"}
      class={[
        "inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
        @filter == "archived" && "bg-gray-900 text-white dark:bg-white dark:text-gray-900",
        @filter != "archived" &&
          "bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
      ]}
    >
      {t("Archived")}
    </.link>
  </div>

  <%!-- Empty state --%>
  <%= if Enum.empty?(@__resources__) do %>
    <div class="flex items-center justify-center py-16">
      <div class="text-center">
        <.icon
          name="hero-rectangle-stack"
          class="h-12 w-12 text-gray-300 dark:text-gray-600 mx-auto mb-4"
        />
        <h3 class="text-lg font-semibold text-gray-700 dark:text-gray-300">
          {t("No __resources__ found")}
        </h3>
        <p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
          {t("Get started by creating your first __resource__.")}
        </p>
      </div>
    </div>
  <% else %>
    <%!-- Data table --%>
    <div class="overflow-hidden rounded-lg border border-gray-200 dark:border-white/10">
      <table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
        <thead class="bg-gray-50 dark:bg-gray-800/50">
          <tr>
            <th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
              {t("Name")}
            </th>
            <th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
              {t("Status")}
            </th>
            <th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
              {t("Created")}
            </th>
            <th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
              {t("Actions")}
            </th>
          </tr>
        </thead>
        <tbody class="divide-y divide-gray-100 dark:divide-white/5 bg-white dark:bg-gray-900">
          <%= for item <- @__resources__ do %>
            <tr
              class="hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors cursor-pointer"
              phx-click={JS.navigate(~p"/__resources__/#{item.id}")}
            >
              <td class="px-4 py-3 text-sm font-medium text-gray-900 dark:text-white">
                {item.name}
              </td>
              <td class="px-4 py-3">
                <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300">
                  Active
                </span>
              </td>
              <td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
                {Calendar.strftime(item.inserted_at, "%b %d, %Y")}
              </td>
              <td class="px-4 py-3 text-right">
                <div class="flex items-center justify-end gap-2">
                  <.link
                    navigate={~p"/__resources__/#{item.id}/edit"}
                    class="text-xs px-2 py-1 rounded bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 transition-colors"
                  >
                    {t("Edit")}
                  </.link>
                  <button
                    type="button"
                    phx-click="delete"
                    phx-value-id={item.id}
                    data-confirm={t("Are you sure you want to delete this?")}
                    class="text-xs px-2 py-1 rounded bg-red-50 text-red-700 hover:bg-red-100 dark:bg-red-900/20 dark:text-red-400 dark:hover:bg-red-900/40 transition-colors"
                  >
                    {t("Delete")}
                  </button>
                </div>
              </td>
            </tr>
          <% end %>
        </tbody>
      </table>
    </div>
  <% end %>
</Layouts.authenticated>

Router entry

# In lib/towerops_web/router.ex, inside the authenticated scope:
live "/__resources__", __RESOURCE__Live.Index, :index