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}| Actions |
#{rows}
""",
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,
"""
""",
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