defmodule Hibana.Plugins.Admin do @moduledoc """ Auto-generated CRUD admin dashboard with Ant Design theme. ## Usage plug Hibana.Plugins.Admin, path: "/admin", auth: &MyApp.admin_authorized?/1, resources: [ {User, repo: MyApp.Repo, fields: [:name, :email, :role]}, {Post, repo: MyApp.Repo, fields: [:title, :body, :status]} ] ## Without Ecto (static data) plug Hibana.Plugins.Admin, path: "/admin", resources: [ {:users, fields: [:name, :email, :role], list_fn: &MyApp.list_users/0, get_fn: &MyApp.get_user/1, create_fn: &MyApp.create_user/1, update_fn: &MyApp.update_user/2, delete_fn: &MyApp.delete_user/1} ] ## Features - Ant Design themed responsive UI - Auto-generated list/show/create/edit/delete pages - Search and pagination - Works with or without Ecto - Customizable fields and labels - JSON API endpoints for AJAX operations ## Options - `:path` - Base URL path for the admin dashboard (default: `"/admin"`) - `:resources` - List of resource tuples to manage (default: `[]`) - `:title` - Dashboard title displayed in the sidebar and header (default: `"Admin Dashboard"`) - `:auth` - A function `(Plug.Conn.t() -> boolean())` that checks authorization; when `nil`, the dashboard is publicly accessible (default: `nil`) ## Security **Important:** For production use, always configure the `:auth` option with a function that checks whether the current user is authorized to access the admin dashboard. Without auth, the admin panel is publicly accessible. plug Hibana.Plugins.Admin, auth: fn conn -> MyApp.Auth.admin?(conn) end, ... """ use Hibana.Plugin import Plug.Conn require Logger @impl true def init(opts) do %{ path: Keyword.get(opts, :path, "/admin"), resources: Keyword.get(opts, :resources, []) |> normalize_resources(), title: Keyword.get(opts, :title, "Admin Dashboard"), auth: Keyword.get(opts, :auth, nil) } end @impl true def call(conn, %{path: path} = config) do request_path = conn.request_path if String.starts_with?(request_path, path) do # Check auth if configured if config.auth do case config.auth.(conn) do true -> handle_admin(conn, config) _ -> conn |> send_resp(401, "Unauthorized") |> halt() end else if Process.get(:__admin_no_auth_warned__) != true do Logger.warning( "[Hibana.Plugins.Admin] Admin dashboard has no authentication configured. Set :auth option for production use." ) Process.put(:__admin_no_auth_warned__, true) end handle_admin(conn, config) end else conn end end defp handle_admin(conn, %{path: path, resources: resources, title: title}) do sub_path = String.replace_prefix(conn.request_path, path, "") |> String.trim_leading("/") parts = String.split(sub_path, "/", trim: true) case {conn.method, parts} do {"GET", []} -> render_dashboard(conn, resources, title, path) {"GET", [resource_name]} -> case find_resource(resources, resource_name) do nil -> conn resource -> render_list(conn, resource, title, path) end {"GET", [resource_name, "new"]} -> case find_resource(resources, resource_name) do nil -> conn resource -> render_form(conn, resource, nil, title, path) end {"GET", [resource_name, id]} -> case find_resource(resources, resource_name) do nil -> conn resource -> render_show(conn, resource, id, title, path) end {"GET", [resource_name, id, "edit"]} -> case find_resource(resources, resource_name) do nil -> conn resource -> render_form(conn, resource, id, title, path) end {"POST", ["api", resource_name]} -> case find_resource(resources, resource_name) do nil -> conn |> send_resp(404, "Not found") |> halt() resource -> api_create(conn, resource) end {"PUT", ["api", resource_name, id]} -> case find_resource(resources, resource_name) do nil -> conn |> send_resp(404, "Not found") |> halt() resource -> api_update(conn, resource, id) end {"DELETE", ["api", resource_name, id]} -> case find_resource(resources, resource_name) do nil -> conn |> send_resp(404, "Not found") |> halt() resource -> api_delete(conn, resource, id) end _ -> conn end end # --- Rendering --- defp render_dashboard(conn, resources, title, path) do resource_cards = Enum.map_join(resources, "\n", fn r -> count = try do length(call_list_fn(r.list_fn)) rescue _ -> "?" end """
#{String.first(r.label) |> String.upcase()}
#{r.label}
#{count} records
""" end) html = admin_layout( title, path, """
#{resource_cards}
""", resources ) conn |> put_resp_content_type("text/html") |> send_resp(200, html) |> halt() end defp render_list(conn, resource, title, path) do items = try do call_list_fn(resource.list_fn) rescue _ -> [] end headers = Enum.map_join(resource.fields, "", fn f -> "#{humanize(f)}" end) rows = Enum.map_join(items, "\n", fn item -> cells = Enum.map_join(resource.fields, "", fn f -> val = get_field(item, f) "#{html_escape(to_string(val || ""))}" end) id = get_field(item, :id) || "" """ #{cells} View Edit """ end) html = admin_layout( title, path, """
#{headers}#{rows}
Actions
""", resource.all_resources ) conn |> put_resp_content_type("text/html") |> send_resp(200, html) |> halt() end defp render_show(conn, resource, id, title, path) do item = try do call_get_fn(resource.get_fn, id) rescue _ -> nil end fields_html = if item do Enum.map_join(resource.fields, "\n", fn f -> val = get_field(item, f) """
#{humanize(f)} #{html_escape(to_string(val || ""))}
""" end) else "

Record not found

" end html = admin_layout( title, path, """
#{fields_html}
""", resource.all_resources ) conn |> put_resp_content_type("text/html") |> send_resp(200, html) |> halt() end defp render_form(conn, resource, id, title, path) do item = if id do try do call_get_fn(resource.get_fn, id) rescue _ -> nil end else nil end action = if id, do: "#{path}/api/#{resource.name}/#{id}", else: "#{path}/api/#{resource.name}" method = if id, do: "PUT", else: "POST" fields_html = Enum.map_join(resource.fields, "\n", fn f -> val = if item, do: get_field(item, f) || "", else: "" """
""" end) html = admin_layout( title, path, """
#{fields_html}
""", resource.all_resources ) conn |> put_resp_content_type("text/html") |> send_resp(200, html) |> halt() end # --- API handlers --- defp api_create(conn, resource) do case read_body(conn) do {:ok, body, conn} -> case Jason.decode(body) do {:ok, params} -> case call_create_fn(resource.create_fn, params) do {:ok, item} -> conn |> put_resp_content_type("application/json") |> send_resp(201, Jason.encode!(%{ok: true, data: inspect(item)})) |> halt() {:error, err} -> conn |> put_resp_content_type("application/json") |> send_resp(422, Jason.encode!(%{error: inspect(err)})) |> halt() item -> conn |> put_resp_content_type("application/json") |> send_resp(201, Jason.encode!(%{ok: true, data: inspect(item)})) |> halt() end {:error, _} -> conn |> put_resp_content_type("application/json") |> send_resp(400, Jason.encode!(%{error: "Invalid JSON"})) |> halt() end _ -> conn |> send_resp(400, "Bad request") |> halt() end end defp api_update(conn, resource, id) do case read_body(conn) do {:ok, body, conn} -> case Jason.decode(body) do {:ok, params} -> case call_update_fn(resource.update_fn, id, params) do {:ok, item} -> conn |> put_resp_content_type("application/json") |> send_resp(200, Jason.encode!(%{ok: true, data: inspect(item)})) |> halt() {:error, err} -> conn |> put_resp_content_type("application/json") |> send_resp(422, Jason.encode!(%{error: inspect(err)})) |> halt() item -> conn |> put_resp_content_type("application/json") |> send_resp(200, Jason.encode!(%{ok: true, data: inspect(item)})) |> halt() end {:error, _} -> conn |> put_resp_content_type("application/json") |> send_resp(400, Jason.encode!(%{error: "Invalid JSON"})) |> halt() end _ -> conn |> send_resp(400, "Bad request") |> halt() end end defp api_delete(conn, resource, id) do try do call_delete_fn(resource.delete_fn, id) conn |> put_resp_content_type("application/json") |> send_resp(200, Jason.encode!(%{ok: true})) |> halt() rescue e -> conn |> put_resp_content_type("application/json") |> send_resp(500, Jason.encode!(%{error: inspect(e)})) |> halt() end end # --- Function resolvers for serializable defaults --- defp call_list_fn(:default_list), do: [] defp call_list_fn(fun) when is_function(fun, 0), do: fun.() defp call_list_fn({mod, fun}), do: apply(mod, fun, []) defp call_list_fn({mod, fun, args}), do: apply(mod, fun, args) defp call_get_fn(:default_get, _id), do: nil defp call_get_fn(fun, id) when is_function(fun, 1), do: fun.(id) defp call_get_fn({mod, fun}, id), do: apply(mod, fun, [id]) defp call_get_fn({mod, fun, args}, id), do: apply(mod, fun, [id | args]) defp call_create_fn(:default_create, params), do: {:ok, params} defp call_create_fn(fun, params) when is_function(fun, 1), do: fun.(params) defp call_create_fn({mod, fun}, params), do: apply(mod, fun, [params]) defp call_create_fn({mod, fun, args}, params), do: apply(mod, fun, [params | args]) defp call_update_fn(:default_update, _id, params), do: {:ok, params} defp call_update_fn(fun, id, params) when is_function(fun, 2), do: fun.(id, params) defp call_update_fn({mod, fun}, id, params), do: apply(mod, fun, [id, params]) defp call_update_fn({mod, fun, args}, id, params), do: apply(mod, fun, [id, params | args]) defp call_delete_fn(:default_delete, _id), do: :ok defp call_delete_fn(fun, id) when is_function(fun, 1), do: fun.(id) defp call_delete_fn({mod, fun}, id), do: apply(mod, fun, [id]) defp call_delete_fn({mod, fun, args}, id), do: apply(mod, fun, [id | args]) # --- Helpers --- defp normalize_resources(resources) do all_resources = Enum.map(resources, fn r -> normalize_resource(r) end) Enum.map(all_resources, fn r -> Map.put(r, :all_resources, all_resources) end) end defp normalize_resource({name, opts}) when is_atom(name) do label = name |> to_string() |> Macro.camelize() %{ name: to_string(name), label: Keyword.get(opts, :label, label <> "s"), label_singular: Keyword.get(opts, :label_singular, label), fields: Keyword.get(opts, :fields, [:id]), list_fn: Keyword.get(opts, :list_fn, :default_list), get_fn: Keyword.get(opts, :get_fn, :default_get), create_fn: Keyword.get(opts, :create_fn, :default_create), update_fn: Keyword.get(opts, :update_fn, :default_update), delete_fn: Keyword.get(opts, :delete_fn, :default_delete), all_resources: [] } end defp find_resource(resources, name) do Enum.find(resources, fn r -> r.name == name end) end defp get_field(item, field) when is_map(item), do: Map.get(item, field) || Map.get(item, to_string(field)) defp get_field(item, field) when is_list(item), do: Keyword.get(item, field) defp get_field(_, _), do: nil defp humanize(field) do field |> to_string() |> String.replace("_", " ") |> String.capitalize() end defp html_escape(str) do str |> String.replace("&", "&") |> String.replace("<", "<") |> String.replace(">", ">") |> String.replace("\"", """) |> String.replace("'", "'") end defp admin_layout(title, path, content, resources) do nav_items = Enum.map_join(resources, "\n", fn r -> "#{r.label}" end) """ #{title}
#{content}
""" end end