Skip to content

Dynamic Menus

A dynamic menu is a navigation tree where each user sees only the items their permissions allow. You define the full menu once, tag each item with a required permission, and the kit hands every user their own filtered copy. No v-if="canSeeX" scattered across your frontend.

New here? Do the Tutorial first, then come back — this guide assumes you have a running app with a few permissions defined.

The mental model

You define (once, per tenant)        Kit filters (per request)      You render
─────────────────────────────        ─────────────────────────      ──────────
Dashboard   (no permission)     ┐
Users       (needs user.read)   ├──►  menusFor(currentUser)  ──►   MenuTree JSON
  └ Invite  (needs user.write)  │     drops items the user           → your sidebar
Billing     (needs billing.read)┘     can't see, prunes empties

Three moving parts:

  1. Menu items live in the kit (one row each: a label, a path, an optional requiredPermission, a display order, an optional parent for nesting).
  2. MenuProvider builds the tree for a given user — dropping items whose requiredPermission the user lacks and pruning branches that end up empty.
  3. Your frontend fetches the filtered tree and renders it. It never decides visibility itself.

Step 1 — Define your menu items

Each item has: a code (stable id), a label (what users see), a path (where it links), an optional icon, an optional requiredPermission, a displayOrder, and an optional parentId (omit for a top-level item).

  1. Open the admin consoleMenus.
  2. Click New and fill in label / path / icon.
  3. In Required permission, pick the permission a user must hold to see this item (leave blank for "everyone").
  4. To nest, set the new item's Parent to an existing item.
  5. Drag to reorder — the order is saved as displayOrder.

See the Admin Console guide → Menus for the full screen.

# Top-level "Users" item, visible only to holders of user.read:
curl -X POST localhost:8080/admin/api/v1/menus \
  -H 'Authorization: Bearer <token>' \
  -H 'Content-Type: application/json' \
  -d '{
        "tenantId": "default",
        "code": "users",
        "label": "Users",
        "path": "/users",
        "icon": "pi pi-users",
        "requiredPermission": "user.read",
        "displayOrder": 20,
        "parentId": null
      }'

requiredPermission and parentId are optional — omit (or null) for a public, top-level item. See the Admin REST API for the full menus resource (list, tree, update, delete).

Step 2 — Serve the filtered tree to your frontend

Expose one endpoint that returns the current user's tree. The kit does the filtering; you just return what menusFor gives you:

// src/main/java/com/example/myapp/NavController.java
import kr.devslab.kit.menu.MenuProvider;
import kr.devslab.kit.menu.MenuTree;
import kr.devslab.kit.identity.CurrentUserProvider;

@RestController
class NavController {

    private final MenuProvider menus;
    private final CurrentUserProvider users;

    NavController(MenuProvider menus, CurrentUserProvider users) {
        this.menus = menus;
        this.users = users;
    }

    @GetMapping("/api/nav")
    MenuTree nav() {
        return menus.menusFor(users.current().orElseThrow());
    }
}

The response is a MenuTree — a list of root MenuItems, each with its allowed children nested under children:

{
  "roots": [
    { "code": "dashboard", "label": "Dashboard", "path": "/", "icon": "pi pi-home",
      "requiredPermission": null, "children": [] },
    { "code": "users", "label": "Users", "path": "/users", "icon": "pi pi-users",
      "requiredPermission": "user.read", "children": [
        { "code": "users.invite", "label": "Invite", "path": "/users/invite",
          "icon": "pi pi-user-plus", "requiredPermission": "user.write", "children": [] }
      ] }
  ]
}

A user without user.read simply won't see the users node at all — and because its only child is then gone too, nothing dangling is left behind.

Step 3 — Render it

Your frontend renders the tree verbatim. A minimal example:

<script setup>
import { ref, onMounted } from 'vue'
import axios from 'axios'
const roots = ref([])
onMounted(async () => { roots.value = (await axios.get('/api/nav')).data.roots })
</script>

<template>
  <nav>
    <RouterLink v-for="item in roots" :key="item.code" :to="item.path">
      <i :class="item.icon" /> {{ item.label }}
      <!-- recurse into item.children for nested menus -->
    </RouterLink>
  </nav>
</template>

Caching

The per-user tree is cached on the shared cache (keyed by user id), so repeated navigation requests don't recompute it. Editing a user's visible menus evicts their entry automatically — you don't manage this.

Direction of dependency

Menus may reference permissions, but permissions know nothing about menus — the dependency never reverses (a core design principle).

See also