- 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
8 KiB
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