diff --git a/.env.sample b/.env.sample
index 0f1d8df..8cdd36d 100644
--- a/.env.sample
+++ b/.env.sample
@@ -75,6 +75,14 @@ MAIL_FROM_NAME=Claper
# GS_JPG_RESOLUTION=300x300
# LANGUAGES=en,fr,es,it,nl,de
+# == Reverse proxy / IP forwarding (set these if Claper runs behind a load balancer or reverse proxy)
+
+# Comma-separated list of trusted proxy IPs or CIDR ranges whose forwarding headers will be trusted
+# REMOTE_IP_PROXIES=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
+
+# Comma-separated list of headers to inspect for the real client IP (replaces the defaults: forwarded, x-forwarded-for, x-client-ip, x-real-ip)
+# REMOTE_IP_HEADERS=forwarded,x-forwarded-for,x-client-ip,x-real-ip
+
# === OIDC configuration ===
diff --git a/config/config.exs b/config/config.exs
index 51120c3..a5d9e30 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -75,6 +75,8 @@ config :porcelain, driver: Porcelain.Driver.Basic
config :claper, :storage_dir, System.get_env("PRESENTATION_STORAGE_DIR", "priv/static")
+config :flop, repo: Claper.Repo
+
# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.
import_config "#{config_env()}.exs"
diff --git a/config/runtime.exs b/config/runtime.exs
index 843177b..1f66d4a 100644
--- a/config/runtime.exs
+++ b/config/runtime.exs
@@ -110,6 +110,22 @@ s3_public_url =
)
)
+remote_ip_proxies =
+ get_var_from_path_or_env(config_dir, "REMOTE_IP_PROXIES", "")
+ |> String.split(",")
+ |> Enum.map(&String.trim/1)
+ |> Enum.reject(&(&1 == ""))
+
+remote_ip_headers =
+ get_var_from_path_or_env(
+ config_dir,
+ "REMOTE_IP_HEADERS",
+ "forwarded,x-forwarded-for,x-client-ip,x-real-ip"
+ )
+ |> String.split(",")
+ |> Enum.map(&String.trim/1)
+ |> Enum.reject(&(&1 == ""))
+
same_site_cookie = get_var_from_path_or_env(config_dir, "SAME_SITE_COOKIE", "Lax")
secure_cookie =
@@ -192,7 +208,9 @@ config :claper,
email_confirmation: email_confirmation,
allow_unlink_external_provider: allow_unlink_external_provider,
logout_redirect_url: logout_redirect_url,
- languages: languages
+ languages: languages,
+ remote_ip_proxies: remote_ip_proxies,
+ remote_ip_headers: remote_ip_headers
config :claper, :presentations,
max_file_size: max_file_size,
diff --git a/lib/claper/audit.ex b/lib/claper/audit.ex
new file mode 100644
index 0000000..df97fd2
--- /dev/null
+++ b/lib/claper/audit.ex
@@ -0,0 +1,125 @@
+defmodule Claper.Audit do
+ @moduledoc """
+ The Audit context for tracking actions in the system.
+ """
+
+ import Ecto.Query, only: [from: 2]
+
+ alias Claper.{Accounts, Repo}
+ alias Claper.Audit.Log
+
+ @doc """
+ Logs an action.
+
+ ## Examples
+
+ iex> log_action(user, "user.login", %{ip_address: "127.0.0.1"})
+ {:ok, %Log{}}
+
+ iex> log_action(nil, "system.startup", %{})
+ {:ok, %Log{}}
+
+ """
+ def log_action(user, action, metadata \\ %{})
+
+ def log_action(%Accounts.User{} = user, action, metadata) do
+ create_log(%{
+ user_id: user.id,
+ action: action,
+ metadata: metadata
+ })
+ end
+
+ def log_action(nil, action, metadata) do
+ create_log(%{
+ action: action,
+ metadata: metadata
+ })
+ end
+
+ @doc """
+ Logs an action related to a specific resource.
+
+ ## Examples
+
+ iex> log_resource_action(user, "event.create", "event", 123, %{})
+ {:ok, %Log{}}
+
+ """
+ def log_resource_action(
+ %Accounts.User{} = user,
+ action,
+ resource_type,
+ resource_id,
+ metadata \\ %{}
+ ) do
+ create_log(%{
+ user_id: user.id,
+ action: action,
+ resource_type: resource_type,
+ resource_id: resource_id,
+ metadata: metadata
+ })
+ end
+
+ @doc """
+ Returns a paginated, optionally filtered and sorted, list of audit logs.
+ """
+ def list_logs(params \\ %{}) do
+ query = from l in Log, left_join: u in assoc(l, :user), as: :user, preload: [user: u]
+ Flop.validate_and_run!(query, params, for: Log, replace_invalid_params: true)
+ end
+
+ @doc """
+ Returns a list of distinct action types for filtering.
+ """
+ def list_action_types do
+ from(l in Log,
+ distinct: true,
+ select: l.action,
+ order_by: l.action
+ )
+ |> Repo.all()
+ end
+
+ @doc """
+ Gets a single log.
+
+ Raises `Ecto.NoResultsError` if the Log does not exist.
+
+ ## Examples
+
+ iex> get_log!(123)
+ %Log{}
+
+ iex> get_log!(456)
+ ** (Ecto.NoResultsError)
+
+ """
+ def get_log!(id) do
+ Repo.one!(
+ from l in Log, left_join: u in assoc(l, :user), where: l.id == ^id, preload: [user: u]
+ )
+ end
+
+ @doc """
+ Creates a log entry.
+
+ This is a simple wrapper over a low-level insert. You likely want to use
+ `log_action/3` and `log_resource_action/5` instead.
+
+ ## Examples
+
+ iex> create_log(%{action: "user.login"})
+ {:ok, %Log{}}
+
+ iex> create_log(%{action: nil})
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def create_log(attrs \\ %{}) do
+ %Log{}
+ |> Log.changeset(attrs)
+ |> Repo.insert()
+ end
+end
diff --git a/lib/claper/audit/log.ex b/lib/claper/audit/log.ex
new file mode 100644
index 0000000..fef6720
--- /dev/null
+++ b/lib/claper/audit/log.ex
@@ -0,0 +1,45 @@
+defmodule Claper.Audit.Log do
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ @derive {
+ Flop.Schema,
+ max_limit: 100,
+ filterable: [:action, :user_email],
+ sortable: [:inserted_at, :action],
+ default_order: %{
+ order_by: [:inserted_at],
+ order_directions: [:desc]
+ },
+ adapter_opts: [
+ join_fields: [
+ user_email: [
+ binding: :user,
+ field: :email,
+ path: [:user, :email]
+ ]
+ ]
+ ]
+ }
+
+ schema "audit_logs" do
+ field :action, :string
+ field :resource_type, :string
+ field :resource_id, :integer
+ field :metadata, :map, default: %{}
+
+ belongs_to :user, Claper.Accounts.User
+
+ timestamps(updated_at: false)
+ end
+
+ @doc false
+ def changeset(log, attrs) do
+ log
+ |> cast(attrs, [:action, :resource_type, :resource_id, :metadata, :user_id])
+ |> validate_required([:action])
+ |> validate_length(:action, max: 255)
+ |> validate_length(:resource_type, max: 255)
+ |> assoc_constraint(:user)
+ end
+end
diff --git a/lib/claper_web/components/core_components.ex b/lib/claper_web/components/core_components.ex
new file mode 100644
index 0000000..f3d98b8
--- /dev/null
+++ b/lib/claper_web/components/core_components.ex
@@ -0,0 +1,498 @@
+defmodule ClaperWeb.CoreComponents do
+ @moduledoc """
+ Provides core UI components.
+
+ At first glance, this module may seem daunting, but its goal is to provide
+ core building blocks for your application, such as tables, forms, and
+ inputs. The components consist mostly of markup and are well-documented
+ with doc strings and declarative assigns. You may customize and style
+ them in any way you want, based on your application growth and needs.
+
+ The foundation for styling is Tailwind CSS, a utility-first CSS framework,
+ augmented with daisyUI, a Tailwind CSS plugin that provides UI components
+ and themes. Here are useful references:
+
+ * [daisyUI](https://daisyui.com/docs/intro/) - a good place to get
+ started and see the available components.
+
+ * [Tailwind CSS](https://tailwindcss.com) - the foundational framework
+ we build on. You will use it for layout, sizing, flexbox, grid, and
+ spacing.
+
+ * [Heroicons](https://heroicons.com) - see `icon/1` for usage.
+
+ * [Phoenix.Component](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html) -
+ the component system used by Phoenix. Some components, such as `<.link>`
+ and `<.form>`, are defined there.
+
+ """
+ use Phoenix.Component
+ use Gettext, backend: ClaperWeb.Gettext
+
+ alias Phoenix.LiveView.JS
+
+ @doc """
+ Renders flash notices.
+
+ ## Examples
+
+ <.flash kind={:info} flash={@flash} />
+ <.flash kind={:info} phx-mounted={show("#flash")}>Welcome Back!
+ """
+ attr :id, :string, doc: "the optional id of flash container"
+ attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
+ attr :title, :string, default: nil
+ attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
+ attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
+
+ slot :inner_block, doc: "the optional inner block that renders the flash message"
+
+ def flash(assigns) do
+ assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)
+
+ ~H"""
+
hide("##{@id}")}
+ role="alert"
+ class="toast toast-top toast-end z-50"
+ {@rest}
+ >
+
+ <.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" />
+ <.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />
+
+
+
+
+
+ """
+ end
+
+ @doc """
+ Renders a button with navigation support.
+
+ ## Examples
+
+ <.button>Send!
+ <.button phx-click="go" variant="primary">Send!
+ <.button navigate={~p"/"}>Home
+ """
+ attr :rest, :global, include: ~w(href navigate patch method download name value disabled)
+ attr :class, :any
+ attr :variant, :string, values: ~w(primary)
+ slot :inner_block, required: true
+
+ def button(%{rest: rest} = assigns) do
+ variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"}
+
+ assigns =
+ assign_new(assigns, :class, fn ->
+ ["btn", Map.fetch!(variants, assigns[:variant])]
+ end)
+
+ if rest[:href] || rest[:navigate] || rest[:patch] do
+ ~H"""
+ <.link class={@class} {@rest}>
+ {render_slot(@inner_block)}
+
+ """
+ else
+ ~H"""
+
+ """
+ end
+ end
+
+ @doc """
+ Renders an input with label and error messages.
+
+ A `Phoenix.HTML.FormField` may be passed as argument,
+ which is used to retrieve the input name, id, and values.
+ Otherwise all attributes may be passed explicitly.
+
+ ## Types
+
+ This function accepts all HTML input types, considering that:
+
+ * You may also set `type="select"` to render a `