- 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
247 lines
7.2 KiB
Markdown
247 lines
7.2 KiB
Markdown
# LiveView Form Page Template
|
|
|
|
Copy-pasteable template for a new create/edit form page following TowerOps conventions.
|
|
|
|
Replace all `__RESOURCE__` with PascalCase resource (e.g., `Schedule`).
|
|
Replace `__resource__` with snake_case (e.g., `schedule`).
|
|
Replace `__resources__` with plural snake_case (e.g., `schedules`).
|
|
Replace `__CONTEXT__` with context module name (e.g., `Schedules`).
|
|
Replace `__SCHEMA__` with schema module name (e.g., `Schedule`).
|
|
|
|
---
|
|
|
|
## `lib/towerops_web/live/__resource___live/form.ex`
|
|
|
|
```elixir
|
|
defmodule ToweropsWeb.__RESOURCE__Live.Form do
|
|
@moduledoc false
|
|
use ToweropsWeb, :live_view
|
|
|
|
alias Towerops.__CONTEXT__
|
|
alias Towerops.__CONTEXT__.__SCHEMA__
|
|
|
|
@impl true
|
|
def mount(_params, _session, socket) do
|
|
{:ok,
|
|
socket
|
|
|> assign(:timezone, socket.assigns.current_scope.timezone)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_params(params, _url, socket) do
|
|
case socket.assigns.live_action do
|
|
:new ->
|
|
__resource__ = %__SCHEMA__{}
|
|
changeset = __SCHEMA__.changeset(__resource__, %{})
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:page_title, t("New __RESOURCE_TITLE__"))
|
|
|> assign(:__resource__, __resource__)
|
|
|> assign(:form, to_form(changeset))}
|
|
|
|
:edit ->
|
|
__resource__ = __CONTEXT__.get___resource__!(params["id"])
|
|
changeset = __SCHEMA__.changeset(__resource__, %{})
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:page_title, t("Edit __RESOURCE_TITLE__"))
|
|
|> assign(:__resource__, __resource__)
|
|
|> assign(:form, to_form(changeset))}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("validate", %{"__resource__" => params}, socket) do
|
|
changeset =
|
|
socket.assigns.__resource__
|
|
|> __SCHEMA__.changeset(params)
|
|
|> Map.put(:action, :validate)
|
|
|
|
{:noreply, assign(socket, :form, to_form(changeset))}
|
|
end
|
|
|
|
def handle_event("save", %{"__resource__" => params}, socket) do
|
|
scope = socket.assigns.current_scope
|
|
|
|
params =
|
|
params
|
|
|> Map.put("organization_id", scope.organization.id)
|
|
|
|
case socket.assigns.live_action do
|
|
:new -> save___resource__(socket, :create, params)
|
|
:edit -> save___resource__(socket, :update, params)
|
|
end
|
|
end
|
|
|
|
def handle_event("delete", _params, socket) do
|
|
case __CONTEXT__.delete___resource__(socket.assigns.__resource__) do
|
|
{:ok, _} ->
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:info, t("__RESOURCE_TITLE__ deleted"))
|
|
|> push_navigate(to: ~p"/__resources__")}
|
|
|
|
{:error, _changeset} ->
|
|
{:noreply, put_flash(socket, :error, t("Unable to delete __resource__"))}
|
|
end
|
|
end
|
|
|
|
defp save___resource__(socket, :create, params) do
|
|
case __CONTEXT__.create___resource__(params) do
|
|
{:ok, __resource__} ->
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:info, t("__RESOURCE_TITLE__ created"))
|
|
|> push_navigate(to: ~p"/__resources__/#{__resource__.id}")}
|
|
|
|
{:error, changeset} ->
|
|
{:noreply, assign(socket, :form, to_form(changeset))}
|
|
end
|
|
end
|
|
|
|
defp save___resource__(socket, :update, params) do
|
|
case __CONTEXT__.update___resource__(socket.assigns.__resource__, params) do
|
|
{:ok, __resource__} ->
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:info, t("__RESOURCE_TITLE__ updated"))
|
|
|> push_navigate(to: ~p"/__resources__/#{__resource__.id}")}
|
|
|
|
{:error, changeset} ->
|
|
{:noreply, assign(socket, :form, to_form(changeset))}
|
|
end
|
|
end
|
|
end
|
|
```
|
|
|
|
---
|
|
|
|
## `lib/towerops_web/live/__resource___live/form.html.heex`
|
|
|
|
```heex
|
|
<Layouts.authenticated
|
|
flash={@flash}
|
|
current_scope={@current_scope}
|
|
active_page="__active_page__"
|
|
>
|
|
<%!-- Back link / breadcrumb --%>
|
|
<div class="mb-6">
|
|
<.link
|
|
navigate={~p"/__resources__"}
|
|
class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
|
>
|
|
<.icon name="hero-arrow-left" class="h-4 w-4" />
|
|
{t("Back to __RESOURCES_TITLE__")}
|
|
</.link>
|
|
</div>
|
|
|
|
<h1 class="text-xl font-bold text-gray-900 dark:text-white mb-6">{@page_title}</h1>
|
|
|
|
<div class="rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 p-6">
|
|
<.form
|
|
for={@form}
|
|
phx-change="validate"
|
|
phx-submit="save"
|
|
id="__resource__-form"
|
|
>
|
|
<div class="space-y-6">
|
|
<%!-- Name field --%>
|
|
<.input
|
|
field={@form[:name]}
|
|
type="text"
|
|
label={t("Name") <> " *"}
|
|
required
|
|
placeholder={t("e.g., My __RESOURCE_TITLE__")}
|
|
/>
|
|
|
|
<%!-- Description / reason field --%>
|
|
<.input
|
|
field={@form[:description]}
|
|
type="textarea"
|
|
label={t("Description")}
|
|
rows="3"
|
|
placeholder={t("Optional description")}
|
|
/>
|
|
|
|
<%!-- Grid layout for related fields --%>
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<.input
|
|
field={@form[:starts_at]}
|
|
type="datetime-local"
|
|
label={t("Starts At") <> " *"}
|
|
required
|
|
/>
|
|
<.input
|
|
field={@form[:ends_at]}
|
|
type="datetime-local"
|
|
label={t("Ends At") <> " *"}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<%!-- Select field example --%>
|
|
<%#
|
|
<.input
|
|
field={@form[:site_id]}
|
|
type="select"
|
|
label={t("Site")}
|
|
prompt={t("Select a site...")}
|
|
options={Enum.map(@sites, &{&1.name, &1.id})}
|
|
/>
|
|
#%>
|
|
|
|
<%!-- Checkbox example --%>
|
|
<.input
|
|
field={@form[:enabled]}
|
|
type="checkbox"
|
|
label={t("Enabled")}
|
|
/>
|
|
|
|
<%!-- Submit / Cancel / Delete --%>
|
|
<div class="flex items-center justify-between gap-3 pt-4 border-t border-gray-200 dark:border-white/10">
|
|
<%!-- Delete button (edit mode only) --%>
|
|
<div>
|
|
<%= if @live_action == :edit do %>
|
|
<button
|
|
type="button"
|
|
phx-click="delete"
|
|
data-confirm={t("Are you sure you want to delete this? This cannot be undone.")}
|
|
class="rounded-lg px-4 py-2 text-sm font-medium text-red-700 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20 transition-colors"
|
|
>
|
|
{t("Delete")}
|
|
</button>
|
|
<% end %>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-3">
|
|
<.link
|
|
navigate={~p"/__resources__"}
|
|
class="rounded-lg px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800 transition-colors"
|
|
>
|
|
{t("Cancel")}
|
|
</.link>
|
|
<button
|
|
type="submit"
|
|
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500 transition-colors"
|
|
>
|
|
{t("Save")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</.form>
|
|
</div>
|
|
</Layouts.authenticated>
|
|
```
|
|
|
|
---
|
|
|
|
## Router entries
|
|
|
|
```elixir
|
|
# In lib/towerops_web/router.ex, inside the authenticated scope:
|
|
live "/__resources__/new", __RESOURCE__Live.Form, :new
|
|
live "/__resources__/:id/edit", __RESOURCE__Live.Form, :edit
|
|
```
|