task-tracking-system / TaskTrackingSystem.WebApp /MenuAuthorizationService.cs
User
Deploy theme persistence and navigation fix
e184579
Raw
History Blame Contribute Delete
16.6 kB
using System.Net.Http.Json;
using System.Security.Claims;
using TaskTrackingSystem.Shared.Models.Menu;
namespace TaskTrackingSystem.WebApp;
public class MenuAuthorizationService
{
private static readonly HashSet<string> PublicRouteSegments = new(StringComparer.OrdinalIgnoreCase)
{
string.Empty,
"login",
"register",
"error",
"reset-password"
};
private readonly ApiClientService _apiClient;
private readonly UserSessionState _sessionState;
private Task<List<MenuDto>>? _loadingAccessItems;
private Task<HashSet<string>>? _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<MenuDto> 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<List<MenuDto>> GetUserMenusAsync(ClaimsPrincipal user)
{
if (user.Identity?.IsAuthenticated != true)
{
return Task.FromResult(new List<MenuDto>());
}
var roleKey = GetRoleCacheKey(user);
if (_sessionState.CachedAccessRoleId == roleKey && _sessionState.CachedAccessItems != null)
{
return Task.FromResult(_sessionState.CachedAccessItems);
}
return LoadAccessItemsAsync(user, roleKey);
}
public async Task<bool> HasAccessCodeAsync(ClaimsPrincipal user, string accessCode)
{
if (string.IsNullOrWhiteSpace(accessCode))
{
return false;
}
var codes = await GetCurrentAccessCodesAsync(user);
return codes.Contains(accessCode.Trim());
}
public Task<HashSet<string>> GetCurrentAccessCodesAsync(ClaimsPrincipal user)
{
if (user.Identity?.IsAuthenticated != true)
{
return Task.FromResult(new HashSet<string>(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);
}
/// <summary>
/// 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).
/// </summary>
private async Task<List<MenuDto>> 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<List<MenuDto>> LoadAccessItemsAsync(ClaimsPrincipal user, string roleKey)
{
if (_loadingAccessItems is { IsCompleted: false })
{
return _loadingAccessItems;
}
_loadingAccessItems = FetchAccessItemsFromApiAsync(user, roleKey);
return _loadingAccessItems;
}
private async Task<HashSet<string>> 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<string>(StringComparer.OrdinalIgnoreCase);
}
private Task<HashSet<string>> LoadAccessCodesAsync(ClaimsPrincipal user, string roleKey)
{
if (_loadingAccessCodes is { IsCompleted: false })
{
return _loadingAccessCodes;
}
_loadingAccessCodes = FetchAccessCodesFromApiAsync(user, roleKey);
return _loadingAccessCodes;
}
private async Task<HashSet<string>> 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<string>(StringComparer.OrdinalIgnoreCase);
}
var codes = await codesResponse.Content.ReadFromJsonAsync<List<string>>(cancellationToken: cts.Token)
?? new List<string>();
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<string>(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<string>(StringComparer.OrdinalIgnoreCase);
_sessionState.CachedAccessCodes = fallback;
_sessionState.CachedAccessRoleId = roleKey;
return fallback;
}
finally
{
_loadingAccessCodes = null;
}
}
private async Task<List<MenuDto>> 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<MenuDto>();
}
var accessItems = await menuResponse.Content.ReadFromJsonAsync<List<MenuDto>>(cancellationToken: cts.Token)
?? new List<MenuDto>();
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<MenuDto> 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<MenuDto> BuildFallbackMenus(ClaimsPrincipal user)
{
return new List<MenuDto>();
}
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";
}
}