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.

Menu Architecture
RoleMenuItem
TenantMenuOverride
MenuItem Entity
ParentMenuItemId
Tree Structure (Self-ref)
Permission Filtering
Tenant Visibility

MenuItem Entity

MenuItem.csC#
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

MethodEndpointDescriptionAuth
GET/menusGet ALL menu items as admin tree (includes inactive)🔒 Required
GET/menus/myGet filtered menu for current admin (based on permissions & tenant)🔒 Required
GET/menus/my/overridesGet current admin's menu overrides🔒 Required
POST/menusCreate new menu item🔒 Required
PUT/menus/{id}Update menu item🔒 Required
DELETE/menus/{id}Soft-delete menu item🔒 Required
PUT/menus/reorderDrag-drop reorder (bulk update Order + ParentMenuItemId)🔒 Required
PUT/menus/{id}/role-visibilitySet which roles can see this item🔒 Required
POST/menus/overridesSave 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.

Menu Filtering Pipeline
All MenuItems
Filter: IsDeleted = false
Filter: TenantScopeJson matches admin's tenant
Filter: Resource admin has {resource}.view
Filter: RoleMenuItem IsVisible for admin's roles
Apply: TenantMenuOverride
Build: Tree structure

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.

TenantUrlResolver.csC#
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

ScopeWho Sets ItEffect
MenuOverrideScope.PersonalIndividual adminPersonal menu customization
MenuOverrideScope.TenantTenant super adminAll 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

MenusController.csC#
// 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