MishkaCmsCore.Sidebar (Mishka CMS Core v0.0.1)

Copy Markdown View Source

The admin sidebar as data — collected from plugins at runtime, filtered by what the viewer may see, and ordered by what the admin arranged.

The nav used to be a hardcoded literal in MishkaCmsCore.Layouts: every extension's links were spelled out in the core, and every admin who reached the console saw all of them, including /admin/dashboard/account/permissions. This module replaces both halves. An extension declares its own menu and who may see it; nothing in the core names the extension.

The event

"sidebar:menus", a MishkaInstaller.Event.Hook — the sibling of "dashboard:widgets". The payload is %{groups: [Group.t()], items: [Item.t()]} and every plugin appends to either list:

defmodule MishkaBlogWeb.SidebarMenu do
  use MishkaInstaller.Event.Hook, event: "sidebar:menus", initial: %{priority: 40}

  use Gettext, backend: MishkaCmsCore.Gettext

  @items [
    MishkaCmsCore.Sidebar.Item.new!(%{
      key: "blog",
      label: gettext_noop("Blog"),
      icon: "hero-book-open",
      path: "/admin/dashboard/blog",
      module: MishkaBlogWeb.Live.Admin.Dashboard.Home.Index,
      group: "plugins",
      order: 10,
      permissions: [
        "admin:dashboard:blog:post",
        "admin:dashboard:blog:comment"
      ],
      credit_name: "Mishka Blog",
      credit_url: "https://mishka.tools/cms/extensions/blog"
    })
  ]

  @impl true
  def call(%{items: items} = data), do: {:reply, %{data | items: items ++ @items}}
end

A label is marked with gettext_noop/1 and translated when the sidebar is drawn, in the language its admin reads the dashboard in: a descriptor is built at compile time, when nobody's language is known yet.

Groups and items are two flat lists rather than a nested tree so that a group and its contents can come from different extensions — Blog and Documents each contribute one item to the Plugins group the core declares, without either knowing about the other.

Unlike widgets, the payload is seeded with core_menu/0 instead of an empty list. A widget board with no plugins registered is merely empty; a nav with no plugins registered is a console you cannot navigate — so Home, Runtime, Account and Settings are always present, whether or not mishka_installer has finished starting. Enum.uniq_by/2 keeps the FIRST declaration of a key, so a plugin can add sections but never silently redefine a core one.

Permissions

permissions on an item is a list where holding ANY entry grants; menu/1 compares it against the viewer and drops the link when nothing matches. Nothing here reads a URL: the question is "may this user see this link", and the answer needs the user and the item, nothing else.

An entry names the area its data asks for, as a plain string:

  • a screen of one area — Sites, Users, Permissions, Activity, an extension's section — lists that area's name: ["admin:dashboard:runtime:site"];
  • a content list every dashboard admin may read — Pages, Layouts, Components, Error Pages, Stylesheets, JS Hooks, Media, Translations — lists its area and {:under, "admin:dashboard"};
  • the builder lists every name that opens a builder — the page, layout, component and error page areas and admin:dashboard:code — and the builder asks for the type it opens on;
  • Home, Notifications, Settings and the Plugins section list {:under, "admin:dashboard"}.

An API admin holds nothing under admin:dashboard, so it sees no entry and opens no page.

This hides links; it does not guard pages. A hidden link is still reachable by typing its URL. Guarding the page is the page's own job — its LiveView declares who may open it — because that is a server-side check on a known module rather than a string the client controls.

Arrangement

Each group and item exposes two settings, registered by MishkaCmsCore.Sidebar.MenuSettings:

  • sidebar.<key>.order — an integer; overrides the declared order
  • sidebar.<key>.visible — a boolean; false hides the entry from everyone

Both are scope: :both, so a global default can be overridden per site: one CMS can put Blog above Runtime for one tenant without touching another. Values are read through MishkaCmsCore.Settings.get/3 (RAM cache), and a non-integer order or non-boolean visibility is ignored in favour of the declaration rather than crashing the chrome.

Open pages

changed/1 tells this node's open admin LiveViews that the entries' settings changed, and MishkaCmsCore.Layouts.Nav redraws their sidebar. MishkaCmsCore.Runtime.States.SettingsState calls it after it holds a sidebar.* value or an extension's <app>.active switch (MishkaCmsCore.Install.Extensions.active?/2), so a view that reads the menu again reads the new value. A site's change reaches the viewers whose menu reads that site, and the global one reaches every viewer.

Event: runtime:state:sidebar:changed — %{key: site_id}, nil for the global value.

Like the widget board, nothing here is cached: the hook is called at each mount so enabling or disabling a plugin shows up immediately. Everything else is compile-time data and ETS reads, and the viewer's permissions are loaded once per mount rather than once per item.

What is in this file

The whole concern, in one place: Group and Item, the two descriptors a plugin declares. They are small, they are useless apart, and reading the contract next to the code that applies it is the point.

The order is load-bearing: both come FIRST because @core_groups/@core_items call their new!/1 at compile time — a descriptor is built once when this module is compiled, not on every admin mount. The settings registrar stays in its own file (MishkaCmsCore.Sidebar.MenuSettings) because it is a supervised plugin, like its widget twin.

Summary

Functions

Tells this node's open admin LiveViews that the sidebar settings of site_id changed; nil for the global ones, which every viewer reads.

Every declared group and item — core plus every "sidebar:menus" plugin — with anything that is not a valid descriptor discarded and the first declaration of a key winning.

The core's own menu — Home, the Runtime and Account sections, Plugins (the section extensions hang their entry point in), and Settings. This is the payload collect/0 seeds the event with, so the console is navigable even with no plugin registered.

The name and address of the extension that owns the admin page module mounts, for the "Powered by" line under it: %{name: name, url: url} from the credit_name and credit_url one of that extension's entries declares. nil for a core page, a page no entry declares, and an extension that declares both of them on no entry.

The mishka_installer event extensions register their menus on.

The console's Home entry — where someone refused a page is sent back to.

Where user should be sent when a page will not have them — Home if they may see the console, the site root otherwise.

What user may see, ready to render: %{main: [...], footer: [...]}, each a list of Group.t() (with items filled in) and top-level Item.t() interleaved in display order.

Whether user may open the admin page at path, as its sidebar entry declares: true for a path no entry declares, since a link to it is not the sidebar's to judge.

The settings key holding key's position override.

The permissions declared for one of the CORE's pages. See permissions/2.

The permissions declared for key, raising when nothing declares it.

The permissions declared for the LiveView module, or [] when nothing declares it.

The order/visible setting definitions for every declared entry — what MishkaCmsCore.Sidebar.MenuSettings registers with the Settings page.

Subscribes the calling process to the changes changed/1 announces for the menu user is shown: the global ones and those of the site the menu reads. It receives {:sidebar_changed, site_id}.

The settings key holding key's on/off switch.

visible_to?/3 against the site the USER is on — their own, or the master site for a master.

Whether user on site_id sees entry — the per-entry setting, the site's switch over the extension that owns it, and the permissions it declares.

Functions

changed(site_id)

@spec changed(String.t() | nil) :: :ok

Tells this node's open admin LiveViews that the sidebar settings of site_id changed; nil for the global ones, which every viewer reads.

Sidebar.changed(site.id)

collect()

@spec collect() :: %{
  groups: [MishkaCmsCore.Sidebar.Group.t()],
  items: [MishkaCmsCore.Sidebar.Item.t()]
}

Every declared group and item — core plus every "sidebar:menus" plugin — with anything that is not a valid descriptor discarded and the first declaration of a key winning.

Unfiltered: this is the registry, not what a given admin sees. Use menu/1 to render.

core_menu()

@spec core_menu() :: %{
  groups: [MishkaCmsCore.Sidebar.Group.t()],
  items: [MishkaCmsCore.Sidebar.Item.t()]
}

The core's own menu — Home, the Runtime and Account sections, Plugins (the section extensions hang their entry point in), and Settings. This is the payload collect/0 seeds the event with, so the console is navigable even with no plugin registered.

credit(module)

@spec credit(module() | nil) :: %{name: String.t(), url: String.t()} | nil

The name and address of the extension that owns the admin page module mounts, for the "Powered by" line under it: %{name: name, url: url} from the credit_name and credit_url one of that extension's entries declares. nil for a core page, a page no entry declares, and an extension that declares both of them on no entry.

event()

@spec event() :: String.t()

The mishka_installer event extensions register their menus on.

home()

@spec home() :: MishkaCmsCore.Sidebar.Item.t()

The console's Home entry — where someone refused a page is sent back to.

Read rather than hardcoded so the landing page and its gate cannot drift from the declaration everything else uses.

landing_path(user)

@spec landing_path(map() | nil) :: String.t()

Where user should be sent when a page will not have them — Home if they may see the console, the site root otherwise.

Bouncing someone to a page they would also be refused is how a redirect loop starts, so the same Home declaration that gates the console decides whether the console is a valid destination. Read through home/0 rather than hardcoded, so the landing and its gate cannot drift.

Used by MishkaCmsCore.Sidebar.PageGuard for a refusal and by the sign-out page for its "stay signed in" way out — both are the same question.

opens?(user, path)

@spec opens?(map() | nil, String.t()) :: boolean()

Whether user may open the admin page at path, as its sidebar entry declares: true for a path no entry declares, since a link to it is not the sidebar's to judge.

order_key(key)

@spec order_key(String.t()) :: String.t()

The settings key holding key's position override.

permissions(key)

The permissions declared for one of the CORE's pages. See permissions/2.

permissions(items, key)

The permissions declared for key, raising when nothing declares it.

This is the single place a page's gate is written down. A page reads its own:

on_mount {MishkaCmsCore.LiveUserAuth,
          {:require_permission, MishkaBlogWeb.SidebarMenu.permissions("blog.posts")}}

It raises rather than returning [] on a miss, and it takes a STATIC list rather than calling collect/0: both are about the same failure. A page reads this at COMPILE time, when no plugin has registered on the hook yet — so collect/0 would answer [], and a silent [] would mean a page that gates nothing. A typo has to break the build, not the gate.

permissions_for_module(module)

@spec permissions_for_module(module() | nil) :: [
  MishkaCmsCore.Accounts.Checks.AccessPermissions.requirement()
]

The permissions declared for the LiveView module, or [] when nothing declares it.

[] means "no declaration", and MishkaCmsCore.Sidebar.PageGuard treats that as REFUSE, not as "no requirement" — an admin page nobody declared is a page nobody vouched for. Forgetting a declaration therefore locks a page rather than opening it, and the per-extension tests fail the build long before anyone meets that in a browser.

Looked up by module rather than by path on purpose: a path is a string the client spells (and can spell several ways), a module is what the router already resolved.

settings_definitions()

@spec settings_definitions() :: [MishkaCmsCore.Settings.definition()]

The order/visible setting definitions for every declared entry — what MishkaCmsCore.Sidebar.MenuSettings registers with the Settings page.

Each definition inherits the entry's own permissions, so an admin is never offered a switch for a menu they cannot see, and items are grouped under their section's heading. An entry every dashboard admin sees ({:under, "admin:dashboard"}) is arranged by the holders of admin:dashboard:setting alone, even when it also names an area.

subscribe(user)

@spec subscribe(map() | nil) :: :ok

Subscribes the calling process to the changes changed/1 announces for the menu user is shown: the global ones and those of the site the menu reads. It receives {:sidebar_changed, site_id}.

visible_key(key)

@spec visible_key(String.t()) :: String.t()

The settings key holding key's on/off switch.

visible_to?(entry, user)

@spec visible_to?(map(), map() | nil) :: boolean()

visible_to?/3 against the site the USER is on — their own, or the master site for a master.

This is what menu/1 asks, so a caller checking one entry gets the same answer the menu would rather than the global one.

visible_to?(entry, user, site_id)

@spec visible_to?(map(), map() | nil, String.t() | nil) :: boolean()

Whether user on site_id sees entry — the per-entry setting, the site's switch over the extension that owns it, and the permissions it declares.

Public because it is the whole of "may this be drawn", and a caller asking about one entry should not have to rebuild the menu to find out.