towerops/docs/templates/liveview-form.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

7.2 KiB

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

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

<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

# 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