"""
end
diff --git a/lib/ash_admin/components/resource/metadata_table.ex b/lib/ash_admin/components/resource/metadata_table.ex
index 644f35ba..66bc27a8 100644
--- a/lib/ash_admin/components/resource/metadata_table.ex
+++ b/lib/ash_admin/components/resource/metadata_table.ex
@@ -71,6 +71,7 @@ defmodule AshAdmin.Components.Resource.MetadataTable do
attr :resource, :any, required: true
attr :domain, :any, required: true
attr :prefix, :any, required: true
+ attr :current_group, :any, default: nil
def relationship_table(assigns) do
~H"""
@@ -98,9 +99,16 @@ defmodule AshAdmin.Components.Resource.MetadataTable do
{relationship.type}
<.td>
- <.link navigate={"#{@prefix}?domain=#{AshAdmin.Domain.name(@domain)}&resource=#{AshAdmin.Resource.name(relationship.destination)}"}>
- {AshAdmin.Resource.name(relationship.destination)}
-
+ <%= %>
+ <%= if destination_domain_accessible?(relationship, @domain, @current_group) do %>
+ <.link navigate={"#{@prefix}?domain=#{AshAdmin.Domain.name(destination_domain(relationship, @domain))}&resource=#{AshAdmin.Resource.name(relationship.destination)}"}>
+ {AshAdmin.Resource.name(relationship.destination)}
+
+ <% else %>
+
+ {AshAdmin.Resource.name(relationship.destination)} (not accessible)
+
+ <% end %>
<.td class="max-w-sm min-w-sm text-gray-500">
{relationship.description}
@@ -175,4 +183,16 @@ defmodule AshAdmin.Components.Resource.MetadataTable do
|> Ash.Resource.Info.relationships()
|> Enum.sort_by(&(not &1.public?))
end
+
+ defp destination_domain(relationship, fallback \\ nil) do
+ Ash.Resource.Info.domain(relationship.destination) || fallback
+ end
+
+ defp destination_domain_accessible?(relationship, domain, current_group) do
+ # Get the destination domain of the relationship
+ destination_domain = destination_domain(relationship, domain)
+
+ # Use the helper function from AshAdmin.Domain to check accessibility
+ AshAdmin.Domain.domain_accessible_in_group?(destination_domain, current_group)
+ end
end
diff --git a/lib/ash_admin/components/resource/resource.ex b/lib/ash_admin/components/resource/resource.ex
index ff54cb3e..d7eeb085 100644
--- a/lib/ash_admin/components/resource/resource.ex
+++ b/lib/ash_admin/components/resource/resource.ex
@@ -22,6 +22,7 @@ defmodule AshAdmin.Components.Resource do
attr :prefix, :any, default: nil
attr :action_type, :atom
attr :polymorphic_actions, :any
+ attr :current_group, :any, default: nil
def render(assigns) do
~H"""
@@ -90,8 +91,15 @@ defmodule AshAdmin.Components.Resource do
tenant={@tenant}
table={@table}
prefix={@prefix}
+ current_group={@current_group}
+ />
+
-
<.live_component
:if={@action_type == :create}
module={Form}
diff --git a/lib/ash_admin/components/resource/show.ex b/lib/ash_admin/components/resource/show.ex
index 3b07ac3f..da418844 100644
--- a/lib/ash_admin/components/resource/show.ex
+++ b/lib/ash_admin/components/resource/show.ex
@@ -17,6 +17,7 @@ defmodule AshAdmin.Components.Resource.Show do
attr :tenant, :any
attr :table, :any, required: true
attr :prefix, :any, required: true
+ attr :current_group, :any, default: nil
def render(assigns) do
~H"""
@@ -142,11 +143,23 @@ defmodule AshAdmin.Components.Resource.Show do
end
defp render_relationships(assigns, _record, resource) do
- assigns = assign(assigns, resource: resource)
+ # Filter relationships to only include those with accessible domains
+ accessible_relationships =
+ resource
+ |> AshAdmin.Components.Resource.Form.relationships(:show)
+ |> Enum.filter(fn relationship ->
+ AshAdmin.Domain.domain_accessible_in_group?(
+ Ash.Resource.Info.domain(relationship.destination),
+ assigns[:current_group]
+ )
+ end)
+
+ assigns =
+ assign(assigns, resource: resource, accessible_relationships: accessible_relationships)
~H"""
@@ -221,9 +234,10 @@ defmodule AshAdmin.Components.Resource.Show do
cardinality: :one,
name: name,
destination: destination,
- context: context,
- domain: destination_domain
+ context: context
}) do
+ destination_domain = Ash.Resource.Info.domain(destination)
+
case Map.get(record, name) do
nil ->
"None"
@@ -283,6 +297,7 @@ defmodule AshAdmin.Components.Resource.Show do
prefix={@prefix}
skip={[@destination_attribute]}
relationship_name={@relationship_name}
+ current_group={@current_group}
/>
"""
diff --git a/lib/ash_admin/components/resource/table.ex b/lib/ash_admin/components/resource/table.ex
index a822701f..f3ab91c6 100644
--- a/lib/ash_admin/components/resource/table.ex
+++ b/lib/ash_admin/components/resource/table.ex
@@ -20,6 +20,7 @@ defmodule AshAdmin.Components.Resource.Table do
attr :show_sensitive_fields, :list, default: []
attr :actor, :any, default: nil
attr :relationship_name, :atom, default: nil
+ attr :current_group, :any, default: nil
def table(assigns) do
~H"""
diff --git a/lib/ash_admin/domain.ex b/lib/ash_admin/domain.ex
index a01f4d40..db665717 100644
--- a/lib/ash_admin/domain.ex
+++ b/lib/ash_admin/domain.ex
@@ -29,6 +29,18 @@ defmodule AshAdmin.Domain do
default: [],
doc:
"Humanized names for each resource group to appear in the admin area. These will be used as labels in the top navigation dropdown and will be shown sorted as given. If a key for a group does not appear in this mapping, the label will not be rendered."
+ ],
+ group: [
+ type: :atom,
+ default: nil,
+ doc: """
+ The group for filtering multiple admin dashboards. When set, this domain will only appear
+ in admin routes that specify a matching group option. If not set (nil), the domain will
+ only appear in admin routes without group filtering.
+
+ Example:
+ group :sub_app # This domain will only show up in routes with group: :sub_app
+ """
]
]
}
@@ -39,6 +51,43 @@ defmodule AshAdmin.Domain do
@moduledoc """
A domain extension to alter the behavior of a domain in the admin UI.
+
+ ## Group-based Filtering
+
+ Domains can be assigned to groups using the `group` option in the admin configuration.
+ This allows you to create multiple admin dashboards, each showing only the domains that belong
+ to a specific group.
+
+ ### Example
+
+ ```elixir
+ defmodule MyApp.SomeFeatureDomain do
+ use Ash.Domain,
+ extensions: [AshAdmin.Domain]
+
+ admin do
+ show? true
+ group :sub_app # This domain will only appear in admin routes with group: :sub_app
+ end
+
+ # ... rest of domain configuration
+ end
+ ```
+
+ Then in your router:
+ ```elixir
+ ash_admin "/sub_app/admin", group: :sub_app # Will only show domains with group: :sub_app
+ ```
+
+ You might need to define different `live_session_name` for the admin dashboards in your
+ router, depending on the group. For example:
+
+ ```elixir
+ ash_admin "/sub_app/admin", group: :sub_app, live_session_name: :sub_app_admin
+ ```
+
+ Note: If you add a group filter to your admin route but haven't set the corresponding group
+ in your domains' admin configuration, those domains won't appear in the admin interface.
"""
def name(domain) do
@@ -61,6 +110,35 @@ defmodule AshAdmin.Domain do
Spark.Dsl.Extension.get_opt(domain, [:admin], :resource_group_labels, [], true)
end
+ def group(domain) do
+ Spark.Dsl.Extension.get_opt(domain, [:admin], :group, nil, true)
+ end
+
+ @doc """
+ Checks if a destination domain is accessible from the current group context.
+
+ Returns true if:
+ - No group filtering is active (current_group is nil)
+ - The destination domain belongs to the same group as current_group
+ - The destination domain has no group (nil) and current_group is also nil
+ """
+ def domain_accessible_in_group?(destination_domain, current_group) do
+ destination_group = group(destination_domain)
+
+ case {current_group, destination_group} do
+ # No group filtering, ungrouped domain
+ {nil, nil} -> true
+ # No group filtering, but domain has a group
+ {nil, _} -> false
+ # Same group
+ {group, group} -> true
+ # Group filtering active, but domain has no group
+ {_, nil} -> false
+ # Different groups
+ {_, _} -> false
+ end
+ end
+
defp default_name(domain) do
split = domain |> Module.split()
diff --git a/lib/ash_admin/pages/page_live.ex b/lib/ash_admin/pages/page_live.ex
index d9cf0179..3aaeac7d 100644
--- a/lib/ash_admin/pages/page_live.ex
+++ b/lib/ash_admin/pages/page_live.ex
@@ -25,7 +25,7 @@ defmodule AshAdmin.PageLive do
socket
) do
otp_app = socket.endpoint.config(:otp_app)
-
+ group = session["group"]
prefix =
case prefix do
"/" ->
@@ -39,7 +39,7 @@ defmodule AshAdmin.PageLive do
socket = assign(socket, :prefix, prefix)
- domains = domains(otp_app)
+ domains = domains(otp_app) |> filter_domains_by_group(group)
{:ok,
socket
@@ -47,6 +47,7 @@ defmodule AshAdmin.PageLive do
|> assign(:primary_key, nil)
|> assign(:record, nil)
|> assign(:domains, domains)
+ |> assign(:current_group, group)
|> assign(:tenant, session["tenant"])
|> assign(:editing_tenant, false)
|> then(fn socket ->
@@ -103,6 +104,7 @@ defmodule AshAdmin.PageLive do
tables={@tables}
polymorphic_actions={@polymorphic_actions}
prefix={@prefix}
+ current_group={@current_group}
/>
"""
end
@@ -113,6 +115,13 @@ defmodule AshAdmin.PageLive do
|> Enum.filter(&AshAdmin.Domain.show?/1)
end
+ defp filter_domains_by_group(domains, nil), do: domains
+ defp filter_domains_by_group(domains, group) do
+ Enum.filter(domains, fn domain ->
+ AshAdmin.Domain.group(domain) == group
+ end)
+ end
+
defp assign_domain(socket, domain) do
domain =
Enum.find(socket.assigns.domains, fn shown_domain ->
diff --git a/lib/ash_admin/router.ex b/lib/ash_admin/router.ex
index 077a3569..d4f6a1f5 100644
--- a/lib/ash_admin/router.ex
+++ b/lib/ash_admin/router.ex
@@ -60,6 +60,18 @@ defmodule AshAdmin.Router do
* `:live_session_name` - Optional atom to name the `live_session`. Defaults to `:ash_admin`.
+ * `:group` - Optional atom to filter domains by group. Only domains with a matching group will be shown.
+ For example: `group: :sub_app` will only show domains with `group: :sub_app` in their admin configuration.
+ Note: If you specify a group here but haven't set that group in any domain's admin configuration,
+ the admin interface will appear empty. Make sure to configure the group in your domains:
+ ```elixir
+ # In your domain:
+ admin do
+ show? true
+ group :sub_app
+ end
+ ```
+
## Examples
defmodule MyAppWeb.Router do
use Phoenix.Router
@@ -71,7 +83,9 @@ defmodule AshAdmin.Router do
# If you don't have one, see `admin_browser_pipeline/1`
pipe_through [:browser]
- ash_admin "/admin"
+ # Default route - shows all domains that don't have a group set
+ ash_admin "/admin" # Shows all domains with no group filter
+ ash_admin "/sub_app/admin", group: :sub_app # Only shows domains with group: :sub_app
ash_admin "/csp/admin", live_session_name: :ash_admin_csp, csp_nonce_assign_key: :csp_nonce_value
end
end
@@ -100,7 +114,13 @@ defmodule AshAdmin.Router do
live_session opts[:live_session_name] || :ash_admin,
on_mount: List.wrap(opts[:on_mount]),
session:
- {AshAdmin.Router, :__session__, [%{"prefix" => path}, List.wrap(opts[:session])]},
+ {AshAdmin.Router, :__session__, [
+ Map.merge(
+ %{"prefix" => path},
+ if(opts[:group], do: %{"group" => opts[:group]}, else: %{})
+ ),
+ List.wrap(opts[:session])
+ ]},
root_layout: {AshAdmin.Layouts, :root} do
live(
"#{path}/*route",
diff --git a/test/ash_admin_test.exs b/test/ash_admin_test.exs
index a126b81f..ee383f3b 100644
--- a/test/ash_admin_test.exs
+++ b/test/ash_admin_test.exs
@@ -92,4 +92,78 @@ defmodule AshAdmin.Test.AshAdminTest do
end
)
end
+
+ describe "domain grouping" do
+ test "domains without group return nil" do
+ defmodule DomainNoGroup do
+ @moduledoc false
+ use Ash.Domain,
+ extensions: [AshAdmin.Domain]
+
+ admin do
+ show? true
+ end
+
+ resources do
+ resource(AshAdmin.Test.Post)
+ end
+ end
+
+ assert AshAdmin.Domain.group(DomainNoGroup) == nil
+ end
+
+ test "domains with group return their group value" do
+ defmodule DomainWithGroup do
+ @moduledoc false
+ use Ash.Domain,
+ extensions: [AshAdmin.Domain]
+
+ admin do
+ show? true
+ group :sub_app
+ end
+
+ resources do
+ resource(AshAdmin.Test.Post)
+ end
+ end
+
+ assert AshAdmin.Domain.group(DomainWithGroup) == :sub_app
+ end
+
+ test "multiple domains with same group are all visible" do
+ defmodule FirstGroupedDomain do
+ @moduledoc false
+ use Ash.Domain,
+ extensions: [AshAdmin.Domain]
+
+ admin do
+ show? true
+ group :sub_app
+ end
+
+ resources do
+ resource(AshAdmin.Test.Post)
+ end
+ end
+
+ defmodule SecondGroupedDomain do
+ @moduledoc false
+ use Ash.Domain,
+ extensions: [AshAdmin.Domain]
+
+ admin do
+ show? true
+ group :sub_app
+ end
+
+ resources do
+ resource(AshAdmin.Test.Comment)
+ end
+ end
+
+ assert AshAdmin.Domain.group(FirstGroupedDomain) == :sub_app
+ assert AshAdmin.Domain.group(SecondGroupedDomain) == :sub_app
+ end
+ end
end
diff --git a/test/components/top_nav/helpers/dropdown_helper_test.exs b/test/components/top_nav/helpers/dropdown_helper_test.exs
index ff5d3ca5..8ff94c0d 100644
--- a/test/components/top_nav/helpers/dropdown_helper_test.exs
+++ b/test/components/top_nav/helpers/dropdown_helper_test.exs
@@ -7,27 +7,27 @@ defmodule AshAdmin.Test.Components.TopNav.Helpers.DropdownHelperTest do
test "groups resources" do
prefix = "/admin"
current_resource = AshAdmin.Test.Post
- domain = AshAdmin.Test.Domain
+ domain = AshAdmin.Test.DomainA
blog_link = %{
active: false,
group: :group_b,
text: "Blog",
- to: "/admin?domain=Test&resource=Blog"
+ to: "/admin?domain=DomainA&resource=Blog"
}
post_link = %{
active: true,
group: :group_a,
text: "Post",
- to: "/admin?domain=Test&resource=Post"
+ to: "/admin?domain=DomainA&resource=Post"
}
comment_link = %{
active: false,
group: nil,
text: "Comment",
- to: "/admin?domain=Test&resource=Comment"
+ to: "/admin?domain=DomainA&resource=Comment"
}
assert_unordered(
@@ -39,7 +39,7 @@ defmodule AshAdmin.Test.Components.TopNav.Helpers.DropdownHelperTest do
test "groups resources by given order from the domain" do
prefix = "/admin"
current_resource = AshAdmin.Test.Post
- domain = AshAdmin.Test.Domain
+ domain = AshAdmin.Test.DomainA
assert [
[%{group: :group_b, text: "Blog"} = _blog_link],
@@ -51,7 +51,7 @@ defmodule AshAdmin.Test.Components.TopNav.Helpers.DropdownHelperTest do
describe "dropdown_group_labels/3" do
test "returns groups" do
- domain = AshAdmin.Test.Domain
+ domain = AshAdmin.Test.DomainA
assert [group_b: "Group B", group_a: "Group A", group_c: "Group C"] =
DropdownHelper.dropdown_group_labels(domain)
diff --git a/test/cross_domain_relationships_test.exs b/test/cross_domain_relationships_test.exs
new file mode 100644
index 00000000..73bc0aed
--- /dev/null
+++ b/test/cross_domain_relationships_test.exs
@@ -0,0 +1,61 @@
+defmodule AshAdmin.Test.CrossDomainRelationshipsTest do
+ use ExUnit.Case, async: true
+ use Phoenix.ConnTest
+
+ import Phoenix.LiveViewTest
+
+ @endpoint AshAdmin.Test.Endpoint
+
+ setup do
+ # Configure the domains in the application environment
+ Application.put_env(:ash_admin, :ash_domains, [
+ AshAdmin.Test.DomainA,
+ AshAdmin.Test.DomainB
+ ])
+
+ # Create an author in DomainB
+ {:ok, author} =
+ AshAdmin.Test.Author
+ |> Ash.Changeset.for_create(:create, %{name: "Test Author"})
+ |> Ash.create()
+
+ # Create a post in DomainA that references the author
+ {:ok, post} =
+ AshAdmin.Test.Post
+ |> Ash.Changeset.for_create(:create, %{
+ body: "Test Post",
+ author_id: author.id
+ })
+ |> Ash.create()
+
+ %{author: author, post: post}
+ end
+
+ describe "cross-domain relationships" do
+ test "viewing a post shows its author from another domain", %{post: post} do
+ {:ok, view, _html} =
+ live(
+ build_conn(),
+ "/api/admin?domain=#{AshAdmin.Domain.name(AshAdmin.Test.DomainA)}&resource=Post&action_type=read&primary_key=#{post.id}"
+ )
+
+ # The view should show the post's details
+ assert has_element?(view, "[data-test-id='post-body']", post.body)
+
+ # The view should show the relationship to the author
+ assert has_element?(view, "[data-test-id='relationship-author']")
+ end
+
+ test "viewing relationships across domains respects domain boundaries", %{post: post} do
+ {:ok, view, _html} =
+ live(
+ build_conn(),
+ "/api/admin?domain=#{AshAdmin.Domain.name(AshAdmin.Test.DomainA)}&resource=Post&action_type=read&primary_key=#{post.id}"
+ )
+
+ # Verify that cross-domain relationship actions are properly handled
+ # This might mean certain actions are hidden or disabled
+ refute has_element?(view, "[data-test-id='edit-author-button']")
+ end
+ end
+end
diff --git a/test/page_live_test.exs b/test/page_live_test.exs
index 303eca55..63cb16b8 100644
--- a/test/page_live_test.exs
+++ b/test/page_live_test.exs
@@ -59,4 +59,27 @@ defmodule AshAdmin.Test.PageLiveTest do
assert html =~ ~s| Plug.Test.init_test_session(%{})
+ |> fetch_session()
+ |> put_session(:group, :group_b)
+ |> live("/api/sub_app/admin")
+
+ # Should show only domains with group_b
+ assert html =~ "DomainB"
+ refute html =~ "DomainA"
+
+ {:ok, _view, html} =
+ conn
+ |> live("/api/admin")
+
+ # Should show only ungrouped domains
+ assert html =~ "DomainA"
+ assert html =~ "DomainB" # DomainB has group_b, so it shouldn't show up in ungrouped view
+ end
+ end
end
diff --git a/test/support/domain.ex b/test/support/domain_a.ex
similarity index 90%
rename from test/support/domain.ex
rename to test/support/domain_a.ex
index 912afe00..b2c224f5 100644
--- a/test/support/domain.ex
+++ b/test/support/domain_a.ex
@@ -1,4 +1,4 @@
-defmodule AshAdmin.Test.Domain do
+defmodule AshAdmin.Test.DomainA do
@moduledoc false
use Ash.Domain,
extensions: [AshAdmin.Domain]
diff --git a/test/support/domain_b.ex b/test/support/domain_b.ex
new file mode 100644
index 00000000..020deb84
--- /dev/null
+++ b/test/support/domain_b.ex
@@ -0,0 +1,14 @@
+defmodule AshAdmin.Test.DomainB do
+ @moduledoc false
+ use Ash.Domain,
+ extensions: [AshAdmin.Domain]
+
+ admin do
+ show? true
+ group(:group_b)
+ end
+
+ resources do
+ resource(AshAdmin.Test.Author)
+ end
+end
diff --git a/test/support/resources/author.ex b/test/support/resources/author.ex
new file mode 100644
index 00000000..74cf3ede
--- /dev/null
+++ b/test/support/resources/author.ex
@@ -0,0 +1,28 @@
+defmodule AshAdmin.Test.Author do
+ @moduledoc false
+ use Ash.Resource,
+ domain: AshAdmin.Test.DomainB,
+ data_layer: Ash.DataLayer.Ets,
+ extensions: [AshAdmin.Resource]
+
+ attributes do
+ uuid_primary_key(:id)
+
+ attribute :name, :string do
+ allow_nil?(false)
+ public?(true)
+ end
+ end
+
+ actions do
+ defaults([:read, :update, :destroy])
+
+ create :create do
+ accept([:name])
+ end
+ end
+
+ admin do
+ resource_group(:group_b)
+ end
+end
diff --git a/test/support/resources/blog.ex b/test/support/resources/blog.ex
index 35063a2b..a11e589c 100644
--- a/test/support/resources/blog.ex
+++ b/test/support/resources/blog.ex
@@ -1,7 +1,7 @@
defmodule AshAdmin.Test.Blog do
@moduledoc false
use Ash.Resource,
- domain: AshAdmin.Test.Domain,
+ domain: AshAdmin.Test.DomainA,
data_layer: Ash.DataLayer.Ets,
extensions: [AshAdmin.Resource]
diff --git a/test/support/resources/comment.ex b/test/support/resources/comment.ex
index 55db028c..2c4193af 100644
--- a/test/support/resources/comment.ex
+++ b/test/support/resources/comment.ex
@@ -1,7 +1,7 @@
defmodule AshAdmin.Test.Comment do
@moduledoc false
use Ash.Resource,
- domain: AshAdmin.Test.Domain,
+ domain: AshAdmin.Test.DomainA,
data_layer: Ash.DataLayer.Ets
attributes do
diff --git a/test/support/resources/post.ex b/test/support/resources/post.ex
index 79a33c08..938fff10 100644
--- a/test/support/resources/post.ex
+++ b/test/support/resources/post.ex
@@ -1,7 +1,7 @@
defmodule AshAdmin.Test.Post do
@moduledoc false
use Ash.Resource,
- domain: AshAdmin.Test.Domain,
+ domain: AshAdmin.Test.DomainA,
data_layer: Ash.DataLayer.Ets,
extensions: [AshAdmin.Resource]
@@ -18,6 +18,12 @@ defmodule AshAdmin.Test.Post do
end
end
+ relationships do
+ belongs_to :author, AshAdmin.Test.Author do
+ public?(true)
+ end
+ end
+
actions do
default_accept(:*)
defaults(create: :*)
diff --git a/test/support/router.ex b/test/support/router.ex
index 0c213750..bc7e596f 100644
--- a/test/support/router.ex
+++ b/test/support/router.ex
@@ -27,5 +27,11 @@ defmodule AshAdmin.Test.Router do
live_session_name: :ash_admin_csp_full,
csp_nonce_assign_key: csp_full
)
+
+ # Test route for group-based admin panel
+ ash_admin("/sub_app/admin",
+ live_session_name: :ash_admin_sub_app,
+ group: :group_b
+ )
end
end