Menu System
Dynamic menu tree with permission filtering, tenant scoping, role visibility, and drag-drop reordering.
Menu Architecture
The Menu System uses a self-referencing tree structure. Each MenuItem can have children via ParentMenuItemId. Menus are filtered through a 6-step pipeline before reaching the admin's browser.
MenuItem Entity
public class MenuItem : AuditableEntity<Guid>
{
[Required] [MaxLength(100)]
public string Slug { get; set; } // URL-safe identifier e.g. "user-management"
[Required] [MaxLength(200)]
public string NameEn { get; set; } // English display name
[Required] [MaxLength(200)]
public string NameAr { get; set; } // Arabic display name (RTL)
[MaxLength(500)]
public string? Href { get; set; } // Navigation URL e.g. "/admin/users"
[MaxLength(100)]
public string? Icon { get; set; } // Icon identifier e.g. "Users"
public int Order { get; set; } // Sort position within parent
public Guid? ParentMenuItemId { get; set; } // Self-referencing FK tree
[MaxLength(100)]
public string? Resource { get; set; } // Permission resource e.g. "admins" checks "admins.view"
[MaxLength(2000)]
public string? TenantScopeJson { get; set; } // null = all tenants, ["id1","id2"] = specific
[MaxLength(100)]
public string? FeatureFlag { get; set; } // Optional feature flag dependency
// Navigation
public virtual MenuItem? ParentMenuItem { get; set; }
public virtual ICollection<MenuItem> Children { get; set; } = [];
public virtual ICollection<RoleMenuItem> RoleMenuItems { get; set; } = [];
}Menu API Endpoints
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| GET | /menus | Get ALL menu items as admin tree (includes inactive) | 🔒 Required |
| GET | /menus/my | Get filtered menu for current admin (based on permissions & tenant) | 🔒 Required |
| GET | /menus/my/overrides | Get current admin's menu overrides | 🔒 Required |
| POST | /menus | Create new menu item | 🔒 Required |
| PUT | /menus/{id} | Update menu item | 🔒 Required |
| DELETE | /menus/{id} | Soft-delete menu item | 🔒 Required |
| PUT | /menus/reorder | Drag-drop reorder (bulk update Order + ParentMenuItemId) | 🔒 Required |
| PUT | /menus/{id}/role-visibility | Set which roles can see this item | 🔒 Required |
| POST | /menus/overrides | Save personal/tenant override | 🔒 Required |
| DELETE | /menus/overrides/{id} | Delete override | 🔒 Required |
Menu Filtering Pipeline
When an admin requests their menu (/menus/my), the system applies 6 sequential filters before building the tree structure. This ensures admins only see menu items they have access to.
Custom Domain & Subdomain Menu Scoping
The menu pipeline automatically resolves the active tenant's domain or subdomain to ensure menu links and redirects point to the correct tenant context. Dev environments use query parameter scoping while production environments support custom CNAME domains.
public async Task<string> GetTenantUrlAsync(Guid tenantId, string tenantCode, string path, CancellationToken ct)
{
var baseUrl = _tenancySettings.FrontendBaseUrl.TrimEnd('/');
path = path.TrimStart('/');
// DEV MODE: localhost doesn't support subdomains — always use base URL + query param
if (IsDevMode(baseUrl))
{
var separator = path.Contains('?') ? "&" : "?";
return `${baseUrl}/${path}${separator}_tenant=${tenantCode}`;
}
// PRODUCTION MODE: Resolve tenant's primary verified custom domain or auto-generated domain
var tenantBaseUrl = await GetFrontendBaseUrlAsync(tenantId, ct);
if (!tenantBaseUrl.Equals(baseUrl, StringComparison.OrdinalIgnoreCase))
return `${tenantBaseUrl}/${path}`;
// Fallback: base URL with query param
var sep = path.Contains('?') ? "&" : "?";
return `${baseUrl}/${path}${sep}_tenant=${tenantCode}`;
}Override System
| Scope | Who Sets It | Effect |
|---|---|---|
| MenuOverrideScope.Personal | Individual admin | Personal menu customization |
| MenuOverrideScope.Tenant | Tenant super admin | All admins in tenant see override |
When both User and Tenant overrides exist, User overrides take precedence. This allows individual admins to customize their sidebar while still respecting tenant-level configurations.
Drag-Drop Reorder
// PUT /menus/reorder batch update
// Request body: [{ menuItemId, newOrder, newParentId }, ...]
// Updates both Order AND ParentMenuItemId in a single transaction
// Enables full tree restructuring via drag-drop UI