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" /> +
+

{@title}

+

{msg}

+
+
+ +
+
+ """ + 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 ` + """ + end + + def input(%{type: "checkbox"} = assigns) do + assigns = + assign_new(assigns, :checked, fn -> + Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value]) + end) + + ~H""" +
+ + <.error :for={msg <- @errors}>{msg} +
+ """ + end + + def input(%{type: "select"} = assigns) do + ~H""" +
+ + <.error :for={msg <- @errors}>{msg} +
+ """ + end + + def input(%{type: "textarea"} = assigns) do + ~H""" +
+ + <.error :for={msg <- @errors}>{msg} +
+ """ + end + + # All other inputs text, datetime-local, url, password, etc. are handled here... + def input(assigns) do + ~H""" +
+ + <.error :for={msg <- @errors}>{msg} +
+ """ + end + + # Helper used by inputs to generate form errors + defp error(assigns) do + ~H""" +

+ <.icon name="hero-exclamation-circle" class="size-5" /> + {render_slot(@inner_block)} +

+ """ + end + + @doc """ + Renders a header with title. + """ + slot :inner_block, required: true + slot :subtitle + slot :actions + + def header(assigns) do + ~H""" +
+
+

+ {render_slot(@inner_block)} +

+

+ {render_slot(@subtitle)} +

+
+
{render_slot(@actions)}
+
+ """ + end + + @doc """ + Renders a table with generic styling. + + ## Examples + + <.table id="users" rows={@users}> + <:col :let={user} label="id">{user.id} + <:col :let={user} label="username">{user.username} + + """ + attr :id, :string, required: true + attr :rows, :list, required: true + attr :row_id, :any, default: nil, doc: "the function for generating the row id" + attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row" + + attr :row_item, :any, + default: &Function.identity/1, + doc: "the function for mapping each row before calling the :col and :action slots" + + slot :col, required: true do + attr :label, :string + end + + slot :action, doc: "the slot for showing user actions in the last table column" + + def table(assigns) do + assigns = + with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do + assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end) + end + + ~H""" + + + + + + + + + + + + + +
{col[:label]} + {gettext("Actions")} +
+ {render_slot(col, @row_item.(row))} + +
+ <%= for action <- @action do %> + {render_slot(action, @row_item.(row))} + <% end %> +
+
+ """ + end + + @doc """ + Renders a data list. + + ## Examples + + <.list> + <:item title="Title">{@post.title} + <:item title="Views">{@post.views} + + """ + slot :item, required: true do + attr :title, :string, required: true + end + + def list(assigns) do + ~H""" + + """ + end + + @doc """ + Renders a [Heroicon](https://heroicons.com). + + Heroicons come in three styles – outline, solid, and mini. + By default, the outline style is used, but solid and mini may + be applied by using the `-solid` and `-mini` suffix. + + You can customize the size and colors of the icons by setting + width, height, and background color classes. + + Icons are extracted from the `deps/heroicons` directory and bundled within + your compiled app.css by the plugin in `assets/vendor/heroicons.js`. + + ## Examples + + <.icon name="hero-x-mark" /> + <.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" /> + """ + attr :name, :string, required: true + attr :class, :any, default: "size-4" + + def icon(%{name: "hero-" <> _} = assigns) do + ~H""" + + """ + end + + ## JS Commands + + def show(js \\ %JS{}, selector) do + JS.show(js, + to: selector, + time: 300, + transition: + {"transition-all ease-out duration-300", + "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95", + "opacity-100 translate-y-0 sm:scale-100"} + ) + end + + def hide(js \\ %JS{}, selector) do + JS.hide(js, + to: selector, + time: 200, + transition: + {"transition-all ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100", + "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"} + ) + end + + @doc """ + Translates an error message using gettext. + """ + def translate_error({msg, opts}) do + # When using gettext, we typically pass the strings we want + # to translate as a static argument: + # + # # Translate the number of files with plural rules + # dngettext("errors", "1 file", "%{count} files", count) + # + # However the error messages in our forms and APIs are generated + # dynamically, so we need to translate them by calling Gettext + # with our gettext backend as first argument. Translations are + # available in the errors.po file (as we use the "errors" domain). + if count = opts[:count] do + Gettext.dngettext(ClaperWeb.Gettext, "errors", msg, msg, count, opts) + else + Gettext.dgettext(ClaperWeb.Gettext, "errors", msg, opts) + end + end + + @doc """ + Translates the errors for a field from a keyword list of errors. + """ + def translate_errors(errors, field) when is_list(errors) do + for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts}) + end +end diff --git a/lib/claper_web/components/icons.ex b/lib/claper_web/components/icons.ex new file mode 100644 index 0000000..fe11b36 --- /dev/null +++ b/lib/claper_web/components/icons.ex @@ -0,0 +1,57 @@ +defmodule ClaperWeb.Icons do + use Phoenix.Component + + def eye(assigns) do + ~H""" + + + + + """ + end + + def arrow_up(assigns) do + ~H""" + + + + """ + end + + def arrow_down(assigns) do + ~H""" + + + + """ + end +end diff --git a/lib/claper_web/controllers/user_auth.ex b/lib/claper_web/controllers/user_auth.ex index 383c9f5..2f8ae37 100644 --- a/lib/claper_web/controllers/user_auth.ex +++ b/lib/claper_web/controllers/user_auth.ex @@ -4,10 +4,13 @@ defmodule ClaperWeb.UserAuth do """ use ClaperWeb, :controller + require Logger + import Plug.Conn import Phoenix.Controller + import ClaperWeb.Helpers.ConnUtils, only: [get_client_ip: 1, get_user_agent: 1] - alias Claper.Accounts + alias Claper.{Accounts, Audit} # Make the remember me cookie valid for 60 days. # If you want bump or reduce this value, also change @@ -32,6 +35,11 @@ defmodule ClaperWeb.UserAuth do token = Accounts.generate_user_session_token(user) user_return_to = get_session(conn, :user_return_to) + async_log_action(user, "user.login", %{ + ip_address: get_client_ip(conn), + user_agent: get_user_agent(conn) + }) + conn |> renew_session() |> put_session(:user_token, token) @@ -83,6 +91,11 @@ defmodule ClaperWeb.UserAuth do ClaperWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{}) end + async_log_action(conn.assigns[:current_user], "user.logout", %{ + ip_address: get_client_ip(conn), + user_agent: get_user_agent(conn) + }) + conn |> renew_session() |> delete_resp_cookie(@remember_me_cookie) @@ -161,4 +174,14 @@ defmodule ClaperWeb.UserAuth do defp maybe_store_return_to(conn), do: conn defp signed_in_path(_conn), do: "/events" + + defp async_log_action(user, action, metadata) do + Task.Supervisor.start_child(Claper.TaskSupervisor, fn -> + with {:error, reason} <- Audit.log_action(user, action, metadata) do + Logger.error( + "Error creating #{inspect(action)} audit log for user #{inspect(get_in(user.email))}: #{inspect(reason)}" + ) + end + end) + end end diff --git a/lib/claper_web/endpoint.ex b/lib/claper_web/endpoint.ex index bb783e5..14b2890 100644 --- a/lib/claper_web/endpoint.ex +++ b/lib/claper_web/endpoint.ex @@ -60,6 +60,10 @@ defmodule ClaperWeb.Endpoint do plug(:runtime_session) + plug RemoteIp, + proxies: {Application, :get_env, [:claper, :remote_ip_proxies, []]}, + headers: {Application, :get_env, [:claper, :remote_ip_headers, []]} + plug ClaperWeb.Router def runtime_session(conn, _opts) do diff --git a/lib/claper_web/helpers/conn_utils.ex b/lib/claper_web/helpers/conn_utils.ex new file mode 100644 index 0000000..1865ab1 --- /dev/null +++ b/lib/claper_web/helpers/conn_utils.ex @@ -0,0 +1,31 @@ +defmodule ClaperWeb.Helpers.ConnUtils do + @moduledoc """ + Utility functions for extracting information from Plug.Conn. + """ + + @doc """ + Extracts the client IP address from the connection. + + The `RemoteIp` plug (configured in the endpoint) resolves the real client IP + by inspecting the headers defined in `REMOTE_IP_HEADERS` and trusting only + the proxies listed in `REMOTE_IP_PROXIES`, then rewrites `conn.remote_ip` + before this is called. + """ + def get_client_ip(conn) do + conn.remote_ip |> :inet.ntoa() |> to_string() + end + + @doc """ + Extracts the user agent string from the connection. + + Returns `nil` if no user agent header is present. + """ + def get_user_agent(conn) do + user_agent = Plug.Conn.get_req_header(conn, "user-agent") + + case user_agent do + [ua | _] -> ua + [] -> nil + end + end +end diff --git a/lib/claper_web/live/admin_live/audit_log_live.ex b/lib/claper_web/live/admin_live/audit_log_live.ex new file mode 100644 index 0000000..251ec80 --- /dev/null +++ b/lib/claper_web/live/admin_live/audit_log_live.ex @@ -0,0 +1,74 @@ +defmodule ClaperWeb.AdminLive.AuditLogLive do + use ClaperWeb, :live_view + + alias Claper.Audit + + @impl Phoenix.LiveView + def mount(_params, session, socket) do + with %{"locale" => locale} <- session do + Gettext.put_locale(ClaperWeb.Gettext, locale) + end + + {:ok, socket} + end + + @impl Phoenix.LiveView + def handle_params(params, _url, socket) do + {:noreply, apply_action(socket, socket.assigns.live_action, params)} + end + + defp apply_action(socket, :index, params) do + {logs, meta} = Audit.list_logs(params) + + socket + |> assign(:page_title, gettext("Audit Logs")) + |> assign(:logs, logs) + |> assign(:meta, meta) + |> assign(:form, Phoenix.Component.to_form(meta)) + |> assign_new(:action_types, &Audit.list_action_types/0) + |> assign_new(:fields, fn %{action_types: action_types} -> + [ + user_email: [ + label: nil, + placeholder: gettext("Search by user email"), + type: "text", + op: :ilike_or + ], + action: [ + label: nil, + prompt: gettext("All actions"), + field: :action, + type: "select", + options: action_types + ] + ] + end) + end + + defp apply_action(socket, :show, %{"id" => id}) do + socket + |> assign(:page_title, gettext("Audit Log Details")) + |> assign(:log, Audit.get_log!(id)) + end + + @impl Phoenix.LiveView + def handle_event("filter-logs", unsigned_params, socket) do + flop = Flop.validate!(unsigned_params, for: Audit.Log, replace_invalid_params: true) + to = Flop.Phoenix.build_path(~p"/admin/audit_logs", flop) + + {:noreply, push_patch(socket, to: to)} + end + + defp format_metadata(nil), do: "—" + defp format_metadata(metadata) when map_size(metadata) == 0, do: "—" + + defp format_metadata(metadata) do + Enum.map_join(metadata, ", ", fn {k, v} -> "#{k}: #{v}" end) + end + + defp format_timestamp(%NaiveDateTime{} = timestamp) do + Calendar.strftime(timestamp, "%Y-%m-%d %H:%M:%S\u00A0UTC") + end + + defp format_timestamp(timestamp), do: inspect(timestamp) +end diff --git a/lib/claper_web/live/admin_live/audit_log_live.html.heex b/lib/claper_web/live/admin_live/audit_log_live.html.heex new file mode 100644 index 0000000..b12fd30 --- /dev/null +++ b/lib/claper_web/live/admin_live/audit_log_live.html.heex @@ -0,0 +1,169 @@ +

{@page_title}

+ +<%= if @live_action == :index do %> +
+ <.form + for={@form} + class="grow flex flex-col sm:flex-row gap-3 sm:[&>div]:basis-[250px]" + phx-change="filter-logs" + > + + + + + *]:rounded-l-full last:[&>*]:rounded-r-full text-gray-500 bg-white [&>*]:grid [&>*]:place-content-center" + ]} + page_link_attrs={[ + class: "hover:text-black hover:bg-gray-200" + ]} + current_page_link_attrs={[ + class: "pointer-events-none font-bold text-black" + ]} + > + <:previous attrs={[ + class: + "order-1 border border-gray-300 rounded-l-full h-10 p-5 bg-white flex items-center justify-center aria-disabled:text-gray-300 hover:not-aria-disabled:bg-gray-200" + ]}> + {gettext("Previous")} + + <:next attrs={[ + class: + "order-2 border-t border-r border-b border-gray-300 rounded-r-full h-10 p-5 bg-white flex items-center justify-center aria-disabled:text-gray-300 hover:not-aria-disabled:bg-gray-200" + ]}> + {gettext("Next")} + + <:ellipsis> + + + +
+ {gettext("No results.")}

+ """, + table_attrs: [class: "table table-zebra w-full"], + symbol_asc: ~H""" + + """, + symbol_desc: ~H""" + + """ + ]} + > + <:col :let={log} label={gettext("ID")} field={:id}>{log.id} + <:col :let={log} label={gettext("Action")} field={:action}> + {log.action} + + <:col :let={log} label={gettext("User")} field={:user_email}> + {get_in(log.user.email) || "—"} + + <:col :let={log} label={gettext("Timestamp")} field={:inserted_at}> + {format_timestamp(log.inserted_at)} + + <:col + :let={log} + label={gettext("Metadata")} + field={:metadata} + tbody_td_attrs={[class: "max-w-xs truncate text-sm text-base-content/70"]} + > + {format_metadata(log.metadata)} + + <:action :let={log} tbody_td_attrs={[class: "text-right"]}> + <.link + patch={~p"/admin/audit_logs/#{log}"} + class="btn btn-link btn-sm" + aria-label={gettext("View details for log #%{id}", id: log.id)} + > + + + +
+<% end %> + +<%= if @live_action == :show do %> +
+
+
{gettext("ID")}
+
+ {@log.id} +
+
+
+
{gettext("Action")}
+
+ {@log.action} +
+
+
+
{gettext("User")}
+
+ <%= if @log.user do %> + + {@log.user.email} + <.link + navigate={~p"/admin/users/#{@log.user}"} + class="link link-primary *:inline" + aria-label={gettext("View profile for %{email}", email: @log.user.email)} + > + + + + <% else %> + — + <% end %> +
+
+
+
{gettext("Timestamp")}
+
+ {format_timestamp(@log.inserted_at)} +
+
+ <%= if @log.resource_type do %> +
+
{gettext("Resource Type")}
+
+ {@log.resource_type} +
+
+ <% end %> + <%= if @log.resource_id do %> +
+
{gettext("Resource ID")}
+
+ {@log.resource_id} +
+
+ <% end %> +
+
{gettext("Metadata")}
+
+ <%= if @log.metadata && map_size(@log.metadata) > 0 do %> +
+ <%= for {key, value} <- @log.metadata do %> +
+
{key}:
+
{value}
+
+ <% end %> +
+ <% else %> + — + <% end %> +
+
+
+<% end %> diff --git a/lib/claper_web/router.ex b/lib/claper_web/router.ex index 67c93ae..0c7c9a2 100644 --- a/lib/claper_web/router.ex +++ b/lib/claper_web/router.ex @@ -198,6 +198,9 @@ defmodule ClaperWeb.Router do live "/oidc_providers/new", OidcProviderLive, :new live "/oidc_providers/:id/edit", OidcProviderLive, :edit live "/oidc_providers/:id", OidcProviderLive, :show + + live "/audit_logs", AuditLogLive, :index + live "/audit_logs/:id", AuditLogLive, :show end end end diff --git a/lib/claper_web/templates/layout/admin.html.heex b/lib/claper_web/templates/layout/admin.html.heex index 5385685..43c14ba 100644 --- a/lib/claper_web/templates/layout/admin.html.heex +++ b/lib/claper_web/templates/layout/admin.html.heex @@ -18,7 +18,7 @@
-
+