- 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
225 lines
8 KiB
Markdown
225 lines
8 KiB
Markdown
# 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`
|
|
|
|
```elixir
|
|
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`
|
|
|
|
```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
|
|
|
|
```elixir
|
|
# In lib/towerops_web/router.ex, inside the authenticated scope:
|
|
live "/__resources__", __RESOURCE__Live.Index, :index
|
|
```
|