using System.Net.Http.Json; using System.Security.Claims; using TaskTrackingSystem.Shared.Models.Menu; namespace TaskTrackingSystem.WebApp; public class MenuAuthorizationService { private static readonly HashSet PublicRouteSegments = new(StringComparer.OrdinalIgnoreCase) { string.Empty, "login", "register", "error", "reset-password" }; private readonly ApiClientService _apiClient; private readonly UserSessionState _sessionState; private Task>? _loadingAccessItems; private Task>? _loadingAccessCodes; public MenuAuthorizationService(ApiClientService apiClient, UserSessionState sessionState) { _apiClient = apiClient; _sessionState = sessionState; } public static bool IsPublicRoute(string relativePath) { return PublicRouteSegments.Contains(GetFirstSegment(relativePath)); } public static string GetFirstSegment(string relativePath) { var path = relativePath.Split('?')[0].Trim('/'); if (string.IsNullOrEmpty(path)) { return string.Empty; } return path.Split('/')[0]; } public static string NormalizeMenuHref(string? menuUrl) { if (string.IsNullOrWhiteSpace(menuUrl)) { return "/dashboard"; } return menuUrl.StartsWith('/') ? menuUrl : $"/{menuUrl.TrimStart('/')}"; } public bool IsRouteAllowed(ClaimsPrincipal user, string relativePath) { if (user.Identity?.IsAuthenticated != true) { return IsPublicRoute(relativePath); } if (IsPublicRoute(relativePath)) { return true; } var cleanRelative = relativePath.Split('?')[0].Trim('/'); if (cleanRelative.Equals("audit-logs", StringComparison.OrdinalIgnoreCase)) { if (_sessionState.CachedAccessCodes != null && _sessionState.CachedAccessCodes.Contains("AuditLogs_List")) { return true; } if (_sessionState.CachedAccessItems != null) { return IsRouteAllowed(_sessionState.CachedAccessItems, relativePath); } return false; } var roleKey = GetRoleCacheKey(user); if (_sessionState.CachedAccessRoleId == roleKey && _sessionState.CachedAccessItems != null) { return IsRouteAllowed(_sessionState.CachedAccessItems, relativePath); } return false; } public bool IsRouteAllowed(IReadOnlyList accessItems, string relativePath) { var cleanRelative = relativePath.Split('?')[0].Trim('/'); var firstSegment = cleanRelative.Split('/')[0]; if (PublicRouteSegments.Contains(firstSegment)) { return true; } // Special case for Task Details page route parameter wildcard if (cleanRelative.StartsWith("tasks/", StringComparison.OrdinalIgnoreCase) && cleanRelative.EndsWith("/details", StringComparison.OrdinalIgnoreCase)) { var segments = cleanRelative.Split('/'); if (segments.Length == 3 && long.TryParse(segments[1], out _)) { var hasTasksAccess = accessItems.Any(m => m.MenuCode.Equals("TASKS_LIST", StringComparison.OrdinalIgnoreCase) || m.MenuCode.Equals("TASKS_BOARD", StringComparison.OrdinalIgnoreCase) || m.SubMenus.Any(sm => sm.MenuCode.Equals("TASKS_LIST", StringComparison.OrdinalIgnoreCase) || sm.MenuCode.Equals("TASKS_BOARD", StringComparison.OrdinalIgnoreCase))); if (hasTasksAccess) { return true; } } } // Special case for project-specific Kanban board if (cleanRelative.StartsWith("projects/", StringComparison.OrdinalIgnoreCase) && cleanRelative.EndsWith("/tasks", StringComparison.OrdinalIgnoreCase)) { var segments = cleanRelative.Split('/'); if (segments.Length == 3 && long.TryParse(segments[1], out _)) { var hasBoardAccess = accessItems.Any(m => m.MenuCode.Equals("TASKS_BOARD", StringComparison.OrdinalIgnoreCase) || m.SubMenus.Any(sm => sm.MenuCode.Equals("TASKS_BOARD", StringComparison.OrdinalIgnoreCase))); if (hasBoardAccess) { return true; } } } // Global board route if (cleanRelative.Equals("board", StringComparison.OrdinalIgnoreCase)) { var hasBoardAccess = accessItems.Any(m => m.MenuCode.Equals("TASKS_BOARD", StringComparison.OrdinalIgnoreCase) || m.SubMenus.Any(sm => sm.MenuCode.Equals("TASKS_BOARD", StringComparison.OrdinalIgnoreCase))); if (hasBoardAccess) { return true; } } if (string.IsNullOrEmpty(cleanRelative) || cleanRelative.Equals("dashboard", StringComparison.OrdinalIgnoreCase) || cleanRelative.Equals("home", StringComparison.OrdinalIgnoreCase)) { return MenuCollectionMatchesRoute(accessItems, "dashboard") || MenuCollectionMatchesRoute(accessItems, ""); } return MenuCollectionMatchesRoute(accessItems, cleanRelative); } public Task> GetUserMenusAsync(ClaimsPrincipal user) { if (user.Identity?.IsAuthenticated != true) { return Task.FromResult(new List()); } var roleKey = GetRoleCacheKey(user); if (_sessionState.CachedAccessRoleId == roleKey && _sessionState.CachedAccessItems != null) { return Task.FromResult(_sessionState.CachedAccessItems); } return LoadAccessItemsAsync(user, roleKey); } public async Task HasAccessCodeAsync(ClaimsPrincipal user, string accessCode) { if (string.IsNullOrWhiteSpace(accessCode)) { return false; } var codes = await GetCurrentAccessCodesAsync(user); return codes.Contains(accessCode.Trim()); } public Task> GetCurrentAccessCodesAsync(ClaimsPrincipal user) { if (user.Identity?.IsAuthenticated != true) { return Task.FromResult(new HashSet(StringComparer.OrdinalIgnoreCase)); } var roleKey = GetRoleCacheKey(user); if (_sessionState.CachedAccessRoleId == roleKey && _sessionState.CachedAccessCodes != null) { return Task.FromResult(_sessionState.CachedAccessCodes); } return LoadAccessCodesAsync(user, roleKey); } public void PreloadMenus(ClaimsPrincipal user) { if (user.Identity?.IsAuthenticated != true) { return; } var roleKey = GetRoleCacheKey(user); if (_sessionState.CachedAccessRoleId == roleKey && _sessionState.CachedAccessItems != null) { return; } _ = LoadAccessItemsAsync(user, roleKey); } /// /// When access items are cached, quickly checks the server-side version to see if an admin changed permissions. /// If the version changed, busts the cache and re-fetches the full menu list. /// Falls back to the cached access items if the version check itself fails (e.g. network error). /// private async Task> LoadAccessItemsWithVersionCheckAsync(ClaimsPrincipal user, string roleKey) { try { using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); var client = _apiClient.CreateClient(user); var response = await client.GetAsync("Menu/version", cts.Token); if (response.IsSuccessStatusCode) { var serverVersion = await response.Content.ReadAsStringAsync(cts.Token); serverVersion = serverVersion.Trim('"'); if (serverVersion != _sessionState.CachedAccessVersion) { Console.WriteLine($"[MenuAuth] Access version changed ({_sessionState.CachedAccessVersion} -> {serverVersion}). Reloading access items."); _sessionState.ClearAccessCache(); return await LoadAccessItemsAsync(user, roleKey); } } } catch (Exception ex) { Console.WriteLine($"[MenuAuth] Access version check failed (using cache): {ex.Message}"); } return _sessionState.CachedAccessItems!; } private Task> LoadAccessItemsAsync(ClaimsPrincipal user, string roleKey) { if (_loadingAccessItems is { IsCompleted: false }) { return _loadingAccessItems; } _loadingAccessItems = FetchAccessItemsFromApiAsync(user, roleKey); return _loadingAccessItems; } private async Task> LoadAccessCodesWithVersionCheckAsync(ClaimsPrincipal user, string roleKey) { try { using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); var client = _apiClient.CreateClient(user); var response = await client.GetAsync("Menu/version", cts.Token); if (response.IsSuccessStatusCode) { var serverVersion = await response.Content.ReadAsStringAsync(cts.Token); serverVersion = serverVersion.Trim('"'); if (serverVersion != _sessionState.CachedAccessVersion) { _sessionState.ClearAccessCache(); return await LoadAccessCodesAsync(user, roleKey); } } } catch (Exception ex) { Console.WriteLine($"[MenuAuth] Access code version check failed (using cache): {ex.Message}"); } return _sessionState.CachedAccessCodes ?? new HashSet(StringComparer.OrdinalIgnoreCase); } private Task> LoadAccessCodesAsync(ClaimsPrincipal user, string roleKey) { if (_loadingAccessCodes is { IsCompleted: false }) { return _loadingAccessCodes; } _loadingAccessCodes = FetchAccessCodesFromApiAsync(user, roleKey); return _loadingAccessCodes; } private async Task> FetchAccessCodesFromApiAsync(ClaimsPrincipal user, string roleKey) { try { using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(8)); var client = _apiClient.CreateClient(user); var codesResponse = await client.GetAsync("Menu/access-codes", cts.Token); if (!codesResponse.IsSuccessStatusCode) { Console.WriteLine($"Menu access-codes API failed: {(int)codesResponse.StatusCode} {codesResponse.ReasonPhrase}"); return new HashSet(StringComparer.OrdinalIgnoreCase); } var codes = await codesResponse.Content.ReadFromJsonAsync>(cancellationToken: cts.Token) ?? new List(); var versionResponse = await client.GetAsync("Menu/version", cts.Token); if (versionResponse.IsSuccessStatusCode) { var version = await versionResponse.Content.ReadAsStringAsync(cts.Token); _sessionState.CachedAccessVersion = version.Trim('"'); } var codeSet = new HashSet(codes.Where(code => !string.IsNullOrWhiteSpace(code)).Select(code => code.Trim()), StringComparer.OrdinalIgnoreCase); _sessionState.CachedAccessCodes = codeSet; _sessionState.CachedAccessRoleId = roleKey; return codeSet; } catch (Exception ex) { Console.WriteLine($"Menu access-codes API error: {ex.Message}"); var fallback = new HashSet(StringComparer.OrdinalIgnoreCase); _sessionState.CachedAccessCodes = fallback; _sessionState.CachedAccessRoleId = roleKey; return fallback; } finally { _loadingAccessCodes = null; } } private async Task> FetchAccessItemsFromApiAsync(ClaimsPrincipal user, string roleKey) { try { using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(8)); var client = _apiClient.CreateClient(user); // Fetch access items and version in parallel for efficiency. var accessItemsTask = client.GetAsync("Menu", cts.Token); var versionTask = client.GetAsync("Menu/version", cts.Token); await Task.WhenAll(accessItemsTask, versionTask); var menuResponse = await accessItemsTask; if (!menuResponse.IsSuccessStatusCode) { Console.WriteLine($"Menu API failed: {(int)menuResponse.StatusCode} {menuResponse.ReasonPhrase}"); return new List(); } var accessItems = await menuResponse.Content.ReadFromJsonAsync>(cancellationToken: cts.Token) ?? new List(); if (accessItems.Count == 0) { accessItems = BuildFallbackMenus(user); } var versionResponse = await versionTask; if (versionResponse.IsSuccessStatusCode) { var version = await versionResponse.Content.ReadAsStringAsync(cts.Token); _sessionState.CachedAccessVersion = version.Trim('"'); } _sessionState.CachedAccessItems = accessItems; _sessionState.CachedAccessRoleId = roleKey; return accessItems; } catch (Exception ex) { Console.WriteLine($"Menu API error: {ex.Message}"); var fallbackAccessItems = BuildFallbackMenus(user); _sessionState.CachedAccessItems = fallbackAccessItems; _sessionState.CachedAccessRoleId = roleKey; return fallbackAccessItems; } finally { _loadingAccessItems = null; } } private static bool MenuCollectionMatchesRoute(IReadOnlyList accessItems, string relativePath) { foreach (var menu in accessItems) { if (MenuMatchesRoute(menu, relativePath)) { return true; } foreach (var subMenu in menu.SubMenus) { if (MenuMatchesRoute(subMenu, relativePath)) { return true; } } } return false; } private static bool MenuMatchesRoute(MenuDto menu, string relativePath) { if (string.IsNullOrWhiteSpace(menu.MenuUrl)) { return false; } var cleanMenu = menu.MenuUrl.Split('?')[0].Trim('/'); var cleanRelative = relativePath.Split('?')[0].Trim('/'); var menuSegments = cleanMenu.Split('/'); var relativeSegments = cleanRelative.Split('/'); if (menuSegments.Length != relativeSegments.Length) { return false; } for (int i = 0; i < menuSegments.Length; i++) { var menuSeg = menuSegments[i]; var relSeg = relativeSegments[i]; if (menuSeg.StartsWith('{') && menuSeg.EndsWith('}')) { continue; // Route parameter wildcard } if (!menuSeg.Equals(relSeg, StringComparison.OrdinalIgnoreCase)) { return false; } } return true; } private static List BuildFallbackMenus(ClaimsPrincipal user) { return new List(); } private static string GetRoleCacheKey(ClaimsPrincipal user) { var roleId = user.FindFirst("role_id")?.Value; if (!string.IsNullOrWhiteSpace(roleId)) { return $"id:{roleId}"; } var roleName = user.FindFirst(ClaimTypes.Role)?.Value; if (!string.IsNullOrWhiteSpace(roleName)) { return $"name:{roleName}"; } return "unknown"; } }