User
fix: overall UI
f6d34ff
Raw
History Blame Contribute Delete
69.3 kB
@page "/roles"
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using TaskTrackingSystem.Shared.Models.Menu
@using TaskTrackingSystem.Shared.Models.User
@rendermode @(new InteractiveServerRenderMode(prerender: false))
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@inject ApiClientService ApiClient
@inject IJSRuntime JS
@inject AuthenticationStateProvider AuthStateProvider
@inject MenuAuthorizationService MenuAuthorization
<PageTitle>Roles & Access</PageTitle>
<SuccessAlert Message="@successMessage" />
<div class="space-y-6">
<!-- Page Header -->
<div class="flex items-center justify-between">
<div>
<h2 class="text-3xl font-bold tracking-tight text-slate-900">Roles & Access</h2>
<p class="text-slate-500 mt-1">Manage roles, assign access and permissions, and control visibility.</p>
</div>
@if (canCreateRole)
{
<button @onclick="OpenCreateModal" class="inline-flex items-center rounded-lg bg-violet-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-violet-700 transition-colors shadow-sm">
<i data-lucide="plus" class="h-4 w-4 mr-2"></i>
New Role
</button>
}
</div>
@if (isLoading)
{
<LoadingSpinner Message="Loading roles..." />
}
else
{
<!-- Search Panel -->
<div class="rounded-xl border border-slate-200 bg-white p-4 shadow-sm mb-4">
<div class="flex items-center gap-3">
<div class="relative flex-1 max-w-md">
<input @bind="searchInput" type="text" placeholder="Search roles by name..." class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent transition-all" />
</div>
<button @onclick="ApplySearch" class="inline-flex items-center rounded-lg bg-violet-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-violet-700 transition-colors shadow-sm">
<i data-lucide="search" class="h-4 w-4 mr-2"></i>
Search
</button>
<button @onclick="ResetSearch" class="inline-flex items-center rounded-lg border border-slate-200 bg-white px-4 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50 transition-colors shadow-sm">
<i data-lucide="rotate-ccw" class="h-4 w-4 mr-2 text-slate-500"></i>
Reset
</button>
</div>
</div>
<!-- Roles Table -->
<div class="rounded-xl border border-slate-200 bg-white shadow-sm overflow-hidden">
<table class="w-full">
<thead>
<tr class="border-b border-slate-100 bg-slate-50/60">
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">No.</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Role</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Description</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Created</th>
<th class="px-6 py-3 text-right text-xs font-semibold text-slate-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
@if (PagedRoles.Any())
{
@foreach (var entry in PagedRoles.Select((role, index) => (role, index)))
{
var role = entry.role;
<tr class="hover:bg-slate-50/50 transition-colors">
<td class="px-6 py-4 text-sm font-semibold text-slate-500">@((currentPage - 1) * pageSize + entry.index + 1)</td>
<td class="px-6 py-4">
<div class="flex items-center space-x-3">
<span class="flex h-9 w-9 items-center justify-center rounded-lg bg-indigo-50 text-indigo-600">
<i data-lucide="shield-check" class="h-4 w-4"></i>
</span>
<span class="text-sm font-semibold text-slate-900">@role.Name</span>
</div>
</td>
<td class="px-6 py-4 text-sm text-slate-500 max-w-xs truncate">@(role.Description ?? "-")</td>
<td class="px-6 py-4 text-sm text-slate-400">@DisplayFormats.Date(role.CreatedAt)</td>
<td class="px-6 py-4 text-right">
<div class="flex items-center justify-end space-x-2">
@if (canManageAccess)
{
<button @onclick="() => OpenAccessModal(role)"
class="inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium text-indigo-700 bg-indigo-50 hover:bg-indigo-100 transition-all"
title="Manage Access">
<i data-lucide="shield-check" class="h-3.5 w-3.5"></i>
Access
</button>
}
@if (canEditRole)
{
<button @onclick="() => OpenEditModal(role)" class="rounded-lg p-1.5 text-slate-400 hover:bg-amber-50 hover:text-amber-600 transition-all" title="Edit">
<i data-lucide="pencil" class="h-4 w-4"></i>
</button>
}
@if (canDeleteRole)
{
<button @onclick="() => RequestDeleteConfirmation(role.Id)" class="rounded-lg p-1.5 text-slate-400 hover:bg-rose-50 hover:text-rose-600 transition-all" title="Delete">
<i data-lucide="trash-2" class="h-4 w-4"></i>
</button>
}
</div>
</td>
</tr>
}
}
else
{
<EmptyState ColSpan="5" Icon="shield-check" Title="No roles yet" Subtitle="Create your first role to get started." />
}
</tbody>
</table>
<!-- Pagination -->
<Pagination CurrentPage="currentPage"
TotalPages="TotalPages"
PageSize="pageSize"
TotalCount="TotalCount"
OnPageChanged="HandlePageChanged"
OnPageSizeChanged="HandlePageSizeChanged" />
</div>
}
</div>
@* ===== Create / Edit Role Modal ===== *@
@if (showRoleModal)
{
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div class="bg-white rounded-2xl shadow-lg w-full mx-4 flex flex-col overflow-hidden"
style="max-width: @(isEditing ? "30rem" : "58rem"); max-height: 88vh;">
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-100 bg-slate-50 shrink-0">
<div class="flex items-center gap-3">
<span class="flex h-8 w-8 items-center justify-center rounded-lg bg-violet-600 text-white">
<i data-lucide="@(isEditing ? "pencil" : "shield-plus")" class="h-4 w-4"></i>
</span>
<h3 class="text-base font-semibold text-slate-900">
@(isEditing ? "Edit Role" : "Create New Role")
</h3>
</div>
<button @onclick="CloseRoleModal" class="rounded-lg p-1.5 text-slate-400 hover:bg-slate-100 hover:text-slate-600 transition-all">
<i data-lucide="x" class="h-5 w-5"></i>
</button>
</div>
<!-- Body: two panels on Create, single on Edit -->
<div class="flex overflow-hidden flex-1 min-h-0">
<!-- LEFT: Role Details -->
<div class="p-6 space-y-4 overflow-y-auto @(isEditing ? "w-full" : "w-5/12") border-r border-slate-100">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">
Role Name <span class="text-red-500">*</span>
</label>
<div class="relative">
<input @bind="formName" type="text" placeholder="e.g. Manager, Viewer"
class="w-full rounded-lg border @(isRoleNameError ? "border-red-500 focus:ring-red-500" : "border-slate-200 focus:ring-slate-900") px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:border-transparent transition-all pr-10" />
@if (isRoleNameError)
{
<svg class="absolute right-3 top-2.5 h-4 w-4 text-red-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
}
</div>
@if (isRoleNameError)
{
<p class="mt-1 text-xs text-red-500">@roleNameErrorMsg</p>
}
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">Description</label>
<textarea @bind="formDescription" rows="4" placeholder="What can this role do?"
class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent transition-all resize-none"></textarea>
</div>
@if (!string.IsNullOrEmpty(roleError))
{
<div class="p-3 rounded-lg bg-red-50 border border-red-100 flex items-start gap-2">
<i data-lucide="alert-circle" class="h-4 w-4 text-red-500 mt-0.5 shrink-0"></i>
<p class="text-sm text-red-600">@roleError</p>
</div>
}
@if (!isEditing)
{
<p class="text-xs text-slate-400 flex items-center gap-1">
<i data-lucide="info" class="h-3.5 w-3.5 shrink-0"></i>
Choose what this role can see and do on the right. It will be saved with the role.
</p>
}
</div>
<!-- RIGHT: Access (Create only) -->
@if (!isEditing)
{
<div class="w-7/12 flex flex-col min-h-0 border-l border-slate-100">
<div class="px-5 py-3 border-b border-slate-100 bg-slate-50/60 shrink-0 flex items-center justify-between gap-3">
<div class="flex items-center gap-2 min-w-0">
<i data-lucide="shield-check" class="h-4 w-4 text-violet-500"></i>
<span class="text-sm font-semibold text-slate-700">Assign Access</span>
<span class="hidden md:inline text-xs text-slate-400">Pages control where the role can go. Actions control what it can do.</span>
</div>
<div class="flex items-center gap-2">
<div class="relative w-36 sm:w-44">
<i data-lucide="search" class="pointer-events-none absolute left-2.5 top-2.5 h-3.5 w-3.5 text-slate-400"></i>
<input @bind-value="formAccessSearch" @bind-value:event="oninput" type="text" placeholder="Search"
class="w-full rounded-lg border border-slate-200 bg-white pl-8 pr-3 py-2 text-xs text-slate-700 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent" />
</div>
@if (formAccessItems.Any())
{
<label class="flex items-center gap-1.5 cursor-pointer group whitespace-nowrap">
<input type="checkbox"
checked="@IsAllFormAccessSelected"
@onchange="ToggleSelectAllFormAccess"
class="h-3.5 w-3.5 rounded accent-violet-600" />
<span class="text-xs text-slate-500 group-hover:text-violet-600 transition-colors">All</span>
</label>
}
</div>
</div>
<div class="overflow-y-auto flex-1 px-4 py-4">
@if (isLoadingFormAccessItems)
{
<div class="flex items-center justify-center py-10">
<svg class="animate-spin h-5 w-5 text-violet-500" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</div>
}
else if (!formAccessItems.Any())
{
<p class="text-sm text-slate-400 text-center py-8">No access items found in database.</p>
}
else
{
<div class="mb-4 rounded-xl border border-slate-100 bg-slate-50/60 px-4 py-3 text-xs text-slate-500 flex items-center justify-between">
<span>Page access lets the role open this section. Permissions let it use the buttons and actions inside.</span>
<span class="font-medium text-slate-400">@formSelectedAccessCodes.Count selected</span>
</div>
@if (GetRootAccessItems(true).Any())
{
<div class="grid grid-cols-1 xl:grid-cols-2 gap-3">
@foreach (var parent in GetRootAccessItems(true))
{
var children = GetChildAccessItems(parent, true).ToList();
var parentChecked = formSelectedAccessCodes.Contains(parent.MenuCode);
<div class="rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden">
<label class="flex items-start gap-3 px-4 py-3 bg-slate-50 cursor-pointer hover:bg-slate-50 transition-colors">
<input type="checkbox" checked="@parentChecked"
@onchange="(e) => ToggleAccessSelection(parent, (bool)(e.Value ?? false), isForm: true)"
class="mt-0.5 h-4 w-4 rounded border-slate-300 accent-violet-600" />
<div class="flex min-w-0 flex-1 items-start gap-2">
@if (!string.IsNullOrEmpty(parent.Icon))
{
<i data-lucide="@parent.Icon" class="mt-0.5 h-4 w-4 text-violet-500 shrink-0"></i>
}
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="text-sm font-semibold text-slate-800 truncate">@parent.MenuName</span>
<span class="rounded-full bg-violet-50 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-violet-600">Menu</span>
</div>
<div class="text-[11px] text-slate-400 truncate">@parent.MenuCode</div>
</div>
</div>
</label>
<div class="border-t border-slate-100 px-3 py-3">
@if (parent.Permissions.Any())
{
<div class="mb-3">
<div class="mb-2 flex items-center justify-between px-1">
<span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">Actions</span>
<span class="text-[11px] text-slate-400">CRUD permissions for this page</span>
</div>
<div class="flex flex-wrap gap-2">
@foreach (var action in parent.Permissions)
{
var isActionChecked = formSelectedAccessCodes.Contains(action.PermissionCode);
<label class="inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] cursor-pointer select-none transition-all
@(isActionChecked
? "bg-violet-50 border-violet-200 text-violet-700"
: "bg-white border-slate-200 text-slate-500 hover:border-violet-200 hover:text-violet-500")">
<input type="checkbox" checked="@isActionChecked"
@onchange="(e) => TogglePermissionSelection(action.PermissionCode, (bool)(e.Value ?? false), isForm: true)"
class="h-3.5 w-3.5 rounded accent-violet-600" />
<span>@action.ActionName</span>
</label>
}
</div>
</div>
}
@if (children.Any())
{
<div class="space-y-2">
@foreach (var child in children)
{
var childChecked = formSelectedAccessCodes.Contains(child.MenuCode);
<div class="rounded-xl border border-slate-100 bg-slate-50/50 p-3">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked="@childChecked"
@onchange="(e) => ToggleAccessSelection(child, (bool)(e.Value ?? false), isForm: true)"
class="h-4 w-4 rounded border-slate-300 accent-violet-600" />
<span class="text-sm font-medium text-slate-700">@child.MenuName</span>
</label>
@if (child.Permissions.Any())
{
<div class="mt-2 flex flex-wrap gap-2 pl-6">
@foreach (var action in child.Permissions)
{
var isActionChecked = formSelectedAccessCodes.Contains(action.PermissionCode);
<label class="inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] cursor-pointer select-none transition-all
@(isActionChecked
? "bg-violet-50 border-violet-200 text-violet-700"
: "bg-white border-slate-200 text-slate-500 hover:border-violet-200 hover:text-violet-500")">
<input type="checkbox" checked="@isActionChecked"
@onchange="(e) => TogglePermissionSelection(action.PermissionCode, (bool)(e.Value ?? false), isForm: true)"
class="h-3 w-3 rounded accent-violet-600" />
<span>@action.ActionName</span>
</label>
}
</div>
}
</div>
}
</div>
}
else if (!parent.Permissions.Any())
{
<p class="text-xs text-slate-400">Nothing else is available inside this page.</p>
}
</div>
</div>
}
</div>
}
else
{
<p class="py-10 text-center text-sm text-slate-400">No pages or permissions match your search.</p>
}
}
</div>
</div>
}
</div>
<!-- Footer -->
<div class="flex items-center justify-between px-6 py-4 border-t border-slate-100 bg-slate-50/50 shrink-0">
<span class="text-xs text-slate-400">
@if (!isEditing && formSelectedAccessCodes.Count > 0)
{
<span class="text-violet-600 font-medium">@formSelectedAccessCodes.Count selected item(s)</span>
<span> will be saved</span>
}
</span>
<div class="flex items-center gap-3">
<button @onclick="CloseRoleModal" class="rounded-lg px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-100 transition-colors">
Cancel
</button>
<button @onclick="RequestSaveConfirmation" disabled="@isSaving"
class="inline-flex items-center gap-2 rounded-lg bg-violet-600 px-5 py-2 text-sm font-medium text-white hover:bg-violet-700 disabled:opacity-50 transition-colors shadow-sm">
<svg class="animate-spin h-4 w-4 @(isSaving ? "" : "hidden")" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>
<span class="@(isSaving ? "" : "hidden")">Saving...</span>
<i data-lucide="@(isEditing ? "check" : "shield-plus")" class="h-4 w-4 @(isSaving ? "hidden" : "")"></i>
<span class="@(isSaving ? "hidden" : "")">@(isEditing ? "Update Role" : "Create Role")</span>
</button>
</div>
</div>
</div>
</div>
}
@* ===== Assign Access Modal ===== *@
@* ===== Role Access Modal ===== *@
@if (showAccessModal)
{
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div class="bg-white rounded-2xl shadow-lg w-full max-w-6xl mx-4 overflow-hidden flex flex-col" style="max-height: 88vh;">
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-100 bg-slate-50">
<div class="flex items-center gap-3">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-violet-100 text-violet-600">
<i data-lucide="shield-check" class="h-4 w-4"></i>
</div>
<div>
<h3 class="text-base font-semibold text-slate-900">Role Access - <span class="text-violet-700">@selectedRoleName</span></h3>
<p class="text-xs text-slate-500">Configure access and permissions</p>
</div>
</div>
<button @onclick="CloseAccessModal" class="rounded-lg p-1.5 text-slate-400 hover:bg-white hover:text-slate-600 transition-all">
<i data-lucide="x" class="h-5 w-5"></i>
</button>
</div>
<!-- Select All Bar -->
<div class="px-6 py-3 border-b border-slate-100 bg-slate-50/60 flex items-center justify-between gap-3">
<div class="flex items-center gap-2 min-w-0">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked="@IsAllAccessSelected" @onchange="ToggleSelectAllAccess"
class="h-4 w-4 rounded border-slate-300 accent-violet-600" />
<span class="text-sm font-medium text-slate-700">Select All</span>
</label>
<span class="hidden md:inline text-xs text-slate-400">Menus open pages. Permissions unlock actions.</span>
</div>
<div class="flex items-center gap-2">
<div class="relative w-36 sm:w-48">
<i data-lucide="search" class="pointer-events-none absolute left-2.5 top-2.5 h-3.5 w-3.5 text-slate-400"></i>
<input @bind-value="accessSearch" @bind-value:event="oninput" type="text" placeholder="Search"
class="w-full rounded-lg border border-slate-200 bg-white pl-8 pr-3 py-2 text-xs text-slate-700 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-600 focus:border-transparent" />
</div>
<span class="text-xs text-slate-400 whitespace-nowrap">@selectedAccessCodes.Count selected</span>
</div>
</div>
<!-- Menu Items Body -->
<div class="overflow-y-auto flex-1 px-4 py-4">
@if (isLoadingAccessItems)
{
<div class="flex items-center justify-center py-12">
<svg class="animate-spin h-6 w-6 text-violet-500" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</div>
}
else if (!accessItems.Any())
{
<div class="text-center py-12">
<i data-lucide="shield-check" class="h-10 w-10 mx-auto text-slate-300 mb-3"></i>
<p class="text-sm text-slate-500">No access items found in database.</p>
</div>
}
else
{
<div class="mb-4 rounded-xl border border-slate-100 bg-slate-50/60 px-4 py-3 text-xs text-slate-500 flex items-center justify-between">
<span>Choosing an action also turns on its page. Turning off a page clears the actions inside it.</span>
<span class="font-medium text-slate-400">@selectedAccessCodes.Count selected</span>
</div>
@if (GetRootAccessItems(false).Any())
{
<div class="grid grid-cols-1 xl:grid-cols-2 gap-3">
@foreach (var parent in GetRootAccessItems(false))
{
var children = GetChildAccessItems(parent, false).ToList();
var parentChecked = selectedAccessCodes.Contains(parent.MenuCode);
<div class="rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden">
<label class="flex items-start gap-3 px-4 py-3 bg-slate-50 cursor-pointer hover:bg-slate-50 transition-colors">
<input type="checkbox" checked="@parentChecked"
@onchange="(e) => ToggleAccessSelection(parent, (bool)(e.Value ?? false), isForm: false)"
class="mt-0.5 h-4 w-4 rounded border-slate-300 accent-violet-600" />
<div class="flex min-w-0 flex-1 items-start gap-2">
@if (!string.IsNullOrEmpty(parent.Icon))
{
<i data-lucide="@parent.Icon" class="mt-0.5 h-4 w-4 text-violet-500 shrink-0"></i>
}
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="text-sm font-semibold text-slate-800 truncate">@parent.MenuName</span>
<span class="rounded-full bg-violet-50 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-violet-600">Menu</span>
</div>
<div class="text-[11px] text-slate-400 truncate">@parent.MenuCode</div>
</div>
</div>
</label>
<div class="border-t border-slate-100 px-3 py-3">
@if (parent.Permissions.Any())
{
<div class="mb-3">
<div class="mb-2 flex items-center justify-between px-1">
<span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">Actions</span>
<span class="text-[11px] text-slate-400">CRUD permissions for this page</span>
</div>
<div class="flex flex-wrap gap-2">
@foreach (var action in parent.Permissions)
{
var isActionChecked = selectedAccessCodes.Contains(action.PermissionCode);
<label class="inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] cursor-pointer select-none transition-all
@(isActionChecked
? "bg-violet-50 border-violet-200 text-violet-700"
: "bg-white border-slate-200 text-slate-500 hover:border-violet-200 hover:text-violet-500")">
<input type="checkbox" checked="@isActionChecked"
@onchange="(e) => TogglePermissionSelection(action.PermissionCode, (bool)(e.Value ?? false), isForm: false)"
class="h-3.5 w-3.5 rounded accent-violet-600" />
<span>@action.ActionName</span>
</label>
}
</div>
</div>
}
@if (children.Any())
{
<div class="space-y-2">
@foreach (var child in children)
{
var childChecked = selectedAccessCodes.Contains(child.MenuCode);
<div class="rounded-xl border border-slate-100 bg-slate-50/50 p-3">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked="@childChecked"
@onchange="(e) => ToggleAccessSelection(child, (bool)(e.Value ?? false), isForm: false)"
class="h-4 w-4 rounded border-slate-300 accent-violet-600" />
<span class="text-sm font-medium text-slate-700">@child.MenuName</span>
</label>
@if (child.Permissions.Any())
{
<div class="mt-2 flex flex-wrap gap-2 pl-6">
@foreach (var action in child.Permissions)
{
var isActionChecked = selectedAccessCodes.Contains(action.PermissionCode);
<label class="inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] cursor-pointer select-none transition-all
@(isActionChecked
? "bg-violet-50 border-violet-200 text-violet-700"
: "bg-white border-slate-200 text-slate-500 hover:border-violet-200 hover:text-violet-500")">
<input type="checkbox" checked="@isActionChecked"
@onchange="(e) => TogglePermissionSelection(action.PermissionCode, (bool)(e.Value ?? false), isForm: false)"
class="h-3 w-3 rounded accent-violet-600" />
<span>@action.ActionName</span>
</label>
}
</div>
}
</div>
}
</div>
}
else if (!parent.Permissions.Any())
{
<p class="text-xs text-slate-400">Nothing else is available inside this page.</p>
}
</div>
</div>
}
</div>
}
else
{
<p class="py-10 text-center text-sm text-slate-400">No pages or permissions match your search.</p>
}
}
</div>
<!-- Footer -->
@if (!string.IsNullOrEmpty(accessSaveError))
{
<div class="px-6 pb-2">
<div class="p-3 rounded-lg bg-red-50 border border-red-100">
<p class="text-sm text-red-600">@accessSaveError</p>
</div>
</div>
}
@if (!string.IsNullOrEmpty(accessSaveSuccess))
{
<div class="px-6 pb-2">
<div class="p-3 rounded-lg bg-emerald-50 border border-emerald-100">
<p class="text-sm text-emerald-700">@accessSaveSuccess</p>
</div>
</div>
}
<div class="flex items-center justify-between px-6 py-4 border-t border-slate-100 bg-slate-50/50">
<span class="text-xs text-slate-400">
<i data-lucide="info" class="h-3.5 w-3.5 inline mr-1 text-slate-400"></i>
Changes apply immediately after saving.
</span>
<div class="flex items-center gap-3">
<button @onclick="CloseAccessModal" class="rounded-lg px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-100 transition-colors">
Cancel
</button>
<button @onclick="SaveAccess" disabled="@isSavingAccess"
class="inline-flex items-center gap-2 rounded-lg bg-violet-600 px-5 py-2 text-sm font-medium text-white hover:bg-violet-700 disabled:opacity-50 transition-colors shadow-sm">
<svg class="animate-spin h-4 w-4 @(isSavingAccess ? "" : "hidden")" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>
<span class="@(isSavingAccess ? "" : "hidden")">Saving...</span>
<i data-lucide="save" class="h-4 w-4 @(isSavingAccess ? "hidden" : "")"></i>
<span class="@(isSavingAccess ? "hidden" : "")">Save Access</span>
</button>
</div>
</div>
</div>
</div>
}
@* ===== Confirm Dialog ===== *@
<ConfirmDialog IsVisible="showConfirmDialog"
Title="@confirmTitle"
Message="@confirmMessage"
ConfirmText="@confirmButtonText"
Icon="@confirmIcon"
IconBgClass="@confirmIconBg"
IconTextClass="@confirmIconText"
ButtonClass="@confirmButtonClass"
HideCancel="@confirmHideCancel"
OnConfirm="HandleConfirmAction"
OnCancel="CloseConfirmDialog" />
@code {
private bool isLoading = true;
private bool showRoleModal = false;
private bool showAccessModal = false;
private bool isEditing = false;
private bool isSaving = false;
private bool canCreateRole = false;
private bool canEditRole = false;
private bool canDeleteRole = false;
private bool canManageAccess = false;
private string? roleError;
private bool isRoleNameError = false;
private string roleNameErrorMsg = "";
// Confirm dialog state
private bool showConfirmDialog = false;
private bool confirmHideCancel = false;
private string? successMessage;
private string confirmTitle = "";
private string confirmMessage = "";
private string confirmButtonText = "";
private string confirmIcon = "";
private string confirmIconBg = "";
private string confirmIconText = "";
private string confirmButtonClass = "";
private Func<Task>? confirmAction;
private List<RoleDto> roleList = new();
private List<UserDto> userList = new();
// Search state
private string searchInput = "";
// Pagination state
private int currentPage = 1;
private int pageSize = 10;
private int totalCount = 0;
private int totalPages = 0;
private int TotalPages => totalPages;
private int TotalCount => totalCount;
private IEnumerable<RoleDto> PagedRoles => roleList;
// Role form
private long editingId;
private string formName = "";
private string? formDescription;
// Inline access items in Create form
private List<AccessMenuDto> formAccessItems = new();
private HashSet<string> formSelectedAccessCodes = new();
private string formAccessSearch = "";
private bool isLoadingFormAccessItems = false;
private bool IsAllFormAccessSelected
=> formAccessItems.Count > 0 && formSelectedAccessCodes.Count == (formAccessItems.Count + formAccessItems.Sum(m => m.Permissions.Count));
// Access modal (for editing existing role)
private long selectedRoleId;
private string selectedRoleName = "";
private List<AccessMenuDto> accessItems = new();
private HashSet<string> selectedAccessCodes = new();
private string accessSearch = "";
private bool isLoadingAccessItems = false;
private bool isSavingAccess = false;
private string? accessSaveError;
private string? accessSaveSuccess;
private bool IsAllAccessSelected
=> accessItems.Count > 0 && selectedAccessCodes.Count == (accessItems.Count + accessItems.Sum(m => m.Permissions.Count));
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var user = authState.User;
canCreateRole = await MenuAuthorization.HasAccessCodeAsync(user, "Roles_Create");
canEditRole = await MenuAuthorization.HasAccessCodeAsync(user, "Roles_Update");
canDeleteRole = await MenuAuthorization.HasAccessCodeAsync(user, "Roles_Delete");
canManageAccess = canEditRole;
await LoadRoles();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await JS.InvokeVoidAsync("initIcons");
}
private async Task LoadRoles()
{
isLoading = true;
var client = ApiClient.CreateClient();
try
{
var rolesTask = client.GetFromJsonAsync<PagedResult<RoleDto>>(BuildRolesUrl(), Serialization.CaseInsensitive);
var usersTask = client.GetFromJsonAsync<List<UserDto>>("User", Serialization.CaseInsensitive);
await Task.WhenAll(rolesTask, usersTask);
roleList = rolesTask.Result?.Items ?? new();
totalCount = rolesTask.Result?.TotalCount ?? 0;
totalPages = rolesTask.Result?.TotalPages ?? 0;
currentPage = rolesTask.Result?.Page ?? currentPage;
pageSize = rolesTask.Result?.PageSize ?? pageSize;
userList = usersTask.Result ?? new();
}
catch (Exception ex)
{
Console.WriteLine($"Load roles error: {ex.Message}");
}
finally
{
isLoading = false;
}
}
// Pagination handlers
private async Task HandlePageChanged(int page)
{
currentPage = page;
await LoadRoles();
}
private async Task HandlePageSizeChanged(int size)
{
pageSize = size;
currentPage = 1;
await LoadRoles();
}
private async Task ApplySearch()
{
currentPage = 1;
await LoadRoles();
}
private async Task ResetSearch()
{
searchInput = "";
currentPage = 1;
await LoadRoles();
}
private string BuildRolesUrl()
{
var query = new List<string>
{
$"page={currentPage}",
$"limit={pageSize}"
};
if (!string.IsNullOrWhiteSpace(searchInput))
{
query.Add($"search={Uri.EscapeDataString(searchInput.Trim())}");
}
return $"Role?{string.Join("&", query)}";
}
// ===== Role CRUD =====
private async Task OpenCreateModal()
{
isEditing = false;
editingId = 0;
formName = "";
formDescription = null;
roleError = null;
isRoleNameError = false;
roleNameErrorMsg = "";
formSelectedAccessCodes = new(StringComparer.OrdinalIgnoreCase);
formAccessItems = new();
formAccessSearch = "";
isLoadingFormAccessItems = true;
showRoleModal = true;
StateHasChanged();
try
{
var client = ApiClient.CreateClient();
formAccessItems = await client.GetFromJsonAsync<List<AccessMenuDto>>("Menu/access-items", Serialization.CaseInsensitive) ?? new();
}
catch (Exception ex)
{
Console.WriteLine($"Load form menus error: {ex.Message}");
}
finally
{
isLoadingFormAccessItems = false;
StateHasChanged();
}
}
private void OpenEditModal(RoleDto role)
{
isEditing = true;
editingId = role.Id;
formName = role.Name;
formDescription = role.Description;
roleError = null;
isRoleNameError = false;
roleNameErrorMsg = "";
showRoleModal = true;
}
private void CloseRoleModal()
{
showRoleModal = false;
roleError = null;
isRoleNameError = false;
roleNameErrorMsg = "";
formAccessItems = new();
formSelectedAccessCodes = new(StringComparer.OrdinalIgnoreCase);
formAccessSearch = "";
}
private void ToggleAccessSelection(AccessMenuDto menu, bool isChecked, bool isForm)
{
var targetSet = isForm ? formSelectedAccessCodes : selectedAccessCodes;
var allMenuSource = isForm ? formAccessItems : accessItems;
if (isChecked)
{
AddMenuSelection(menu, allMenuSource, targetSet);
}
else
{
RemoveMenuSelection(menu, allMenuSource, targetSet);
}
StateHasChanged();
}
private void TogglePermissionSelection(string actionCode, bool isChecked, bool isForm)
{
var targetSet = isForm ? formSelectedAccessCodes : selectedAccessCodes;
var menuSource = isForm ? formAccessItems : accessItems;
if (isChecked)
{
targetSet.Add(actionCode);
var permission = FindPermission(actionCode, menuSource);
if (permission != null)
{
EnsureMenuPathSelected(permission.ParentMenuCode, menuSource, targetSet);
EnsureMenuBranchVisible(permission.ParentMenuCode, menuSource, targetSet);
}
}
else
{
targetSet.Remove(actionCode);
}
StateHasChanged();
}
private void ToggleSelectAllFormAccess(ChangeEventArgs e)
{
var selectAll = (bool)(e.Value ?? false);
if (selectAll)
{
formSelectedAccessCodes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var menu in formAccessItems)
{
formSelectedAccessCodes.Add(menu.MenuCode);
foreach (var action in menu.Permissions)
{
formSelectedAccessCodes.Add(action.PermissionCode);
}
}
}
else
{
formSelectedAccessCodes.Clear();
}
}
private static bool IsRootMenu(AccessMenuDto menu)
=> string.IsNullOrWhiteSpace(menu.ParentCode) || menu.ParentCode == "0";
private static bool MatchesSearch(string? value, string search)
=> !string.IsNullOrWhiteSpace(value)
&& value.Contains(search, StringComparison.OrdinalIgnoreCase);
private IEnumerable<AccessMenuDto> GetRootAccessItems(bool isForm)
{
var source = isForm ? formAccessItems : accessItems;
var search = (isForm ? formAccessSearch : accessSearch).Trim();
return source
.Where(IsRootMenu)
.Where(menu => string.IsNullOrWhiteSpace(search) || AccessBranchMatchesSearch(menu, source, search))
.OrderBy(m => m.OrderNo);
}
private IEnumerable<AccessMenuDto> GetChildAccessItems(AccessMenuDto parent, bool isForm)
{
var source = isForm ? formAccessItems : accessItems;
var search = (isForm ? formAccessSearch : accessSearch).Trim();
var children = source
.Where(c => c.ParentCode == parent.MenuCode)
.OrderBy(c => c.OrderNo);
if (string.IsNullOrWhiteSpace(search))
{
return children;
}
return children.Where(child => AccessBranchMatchesSearch(child, source, search));
}
private static bool AccessBranchMatchesSearch(AccessMenuDto menu, IReadOnlyList<AccessMenuDto> source, string search)
{
if (MatchesSearch(menu.MenuName, search) || MatchesSearch(menu.MenuCode, search) || MatchesSearch(menu.MenuUrl, search))
{
return true;
}
if (menu.Permissions.Any(permission =>
MatchesSearch(permission.ActionName, search) ||
MatchesSearch(permission.PermissionCode, search) ||
MatchesSearch(permission.ApiName, search)))
{
return true;
}
return source
.Where(child => child.ParentCode == menu.MenuCode)
.Any(child => AccessBranchMatchesSearch(child, source, search));
}
private static AccessPermissionDto? FindPermission(string actionCode, IReadOnlyList<AccessMenuDto> source)
{
foreach (var menu in source)
{
var permission = menu.Permissions.FirstOrDefault(p => string.Equals(p.PermissionCode, actionCode, StringComparison.OrdinalIgnoreCase));
if (permission != null)
{
return permission;
}
}
return null;
}
private static AccessMenuDto? FindMenu(string menuCode, IReadOnlyList<AccessMenuDto> source)
{
return source.FirstOrDefault(m => string.Equals(m.MenuCode, menuCode, StringComparison.OrdinalIgnoreCase));
}
private static void EnsureMenuPathSelected(string menuCode, IReadOnlyList<AccessMenuDto> source, ISet<string> targetSet)
{
var current = FindMenu(menuCode, source);
while (current != null)
{
targetSet.Add(current.MenuCode);
if (string.IsNullOrWhiteSpace(current.ParentCode) || current.ParentCode == "0")
{
break;
}
current = FindMenu(current.ParentCode, source);
}
}
private static void EnsureMenuBranchVisible(string menuCode, IReadOnlyList<AccessMenuDto> source, ISet<string> targetSet)
{
var menu = FindMenu(menuCode, source);
if (menu == null)
{
return;
}
targetSet.Add(menu.MenuCode);
foreach (var child in GetAutoVisibleChildren(menu, source))
{
EnsureMenuBranchVisible(child.MenuCode, source, targetSet);
}
}
private static void AddMenuSelection(AccessMenuDto menu, IReadOnlyList<AccessMenuDto> source, ISet<string> targetSet)
{
targetSet.Add(menu.MenuCode);
foreach (var permission in menu.Permissions)
{
targetSet.Add(permission.PermissionCode);
}
EnsureMenuPathSelected(menu.ParentCode, source, targetSet);
foreach (var child in GetAutoVisibleChildren(menu, source))
{
EnsureMenuBranchVisible(child.MenuCode, source, targetSet);
}
}
private static void RemoveMenuSelection(AccessMenuDto menu, IReadOnlyList<AccessMenuDto> source, ISet<string> targetSet)
{
targetSet.Remove(menu.MenuCode);
foreach (var permission in menu.Permissions)
{
targetSet.Remove(permission.PermissionCode);
}
foreach (var child in GetAutoVisibleChildren(menu, source))
{
RemoveMenuSelection(child, source, targetSet);
}
CleanupAncestorSelection(menu.ParentCode, source, targetSet);
}
private static void CleanupAncestorSelection(string parentCode, IReadOnlyList<AccessMenuDto> source, ISet<string> targetSet)
{
if (string.IsNullOrWhiteSpace(parentCode) || parentCode == "0")
{
return;
}
var parent = FindMenu(parentCode, source);
if (parent == null)
{
return;
}
if (HasAnySelectedInBranch(parent, source, targetSet))
{
return;
}
targetSet.Remove(parent.MenuCode);
CleanupAncestorSelection(parent.ParentCode, source, targetSet);
}
private static bool HasAnySelectedInBranch(AccessMenuDto menu, IReadOnlyList<AccessMenuDto> source, ISet<string> targetSet)
{
if (targetSet.Contains(menu.MenuCode) || menu.Permissions.Any(p => targetSet.Contains(p.PermissionCode)))
{
return true;
}
return GetAutoVisibleChildren(menu, source)
.Any(child => HasAnySelectedInBranch(child, source, targetSet));
}
private static void NormalizeAccessSelection(IReadOnlyList<AccessMenuDto> source, ISet<string> targetSet)
{
var selectedPermissions = source
.SelectMany(menu => menu.Permissions)
.Where(permission => targetSet.Contains(permission.PermissionCode))
.ToList();
foreach (var permission in selectedPermissions)
{
EnsureMenuPathSelected(permission.ParentMenuCode, source, targetSet);
EnsureMenuBranchVisible(permission.ParentMenuCode, source, targetSet);
}
}
private static IEnumerable<AccessMenuDto> GetAutoVisibleChildren(AccessMenuDto menu, IReadOnlyList<AccessMenuDto> source)
{
return source
.Where(m => m.ParentCode == menu.MenuCode && IsAutoVisiblePageMenu(m))
.OrderBy(m => m.OrderNo)
.ThenBy(m => m.MenuName, StringComparer.OrdinalIgnoreCase);
}
private static bool IsAutoVisiblePageMenu(AccessMenuDto menu)
{
var text = $"{menu.MenuName} {menu.MenuCode} {menu.MenuUrl}".Trim();
return text.Contains("list", StringComparison.OrdinalIgnoreCase)
|| text.Contains("view", StringComparison.OrdinalIgnoreCase)
|| text.Contains("dashboard", StringComparison.OrdinalIgnoreCase)
|| text.Contains("home", StringComparison.OrdinalIgnoreCase);
}
private void RequestSaveConfirmation()
{
isRoleNameError = false;
roleNameErrorMsg = "";
roleError = null;
bool hasError = false;
if (string.IsNullOrWhiteSpace(formName))
{
isRoleNameError = true;
roleNameErrorMsg = ResultMessages.RoleNameRequired;
hasError = true;
}
if (hasError) return;
if (isEditing)
{
confirmTitle = "Update Role?";
confirmMessage = "Are you sure you want to update this role?";
confirmButtonText = "Update";
confirmIcon = "pencil";
confirmIconBg = "bg-amber-50";
confirmIconText = "text-amber-500";
confirmButtonClass = "bg-violet-600 hover:bg-violet-700";
}
else
{
confirmTitle = "Create Role?";
confirmMessage = "Are you sure you want to create this role?";
confirmButtonText = "Create";
confirmIcon = "plus-circle";
confirmIconBg = "bg-emerald-50";
confirmIconText = "text-emerald-500";
confirmButtonClass = "bg-violet-600 hover:bg-violet-700";
}
confirmAction = SaveRole;
showConfirmDialog = true;
}
private void RequestDeleteConfirmation(long id)
{
confirmTitle = "Delete Role?";
confirmMessage = "Are you sure you want to delete this role? This action cannot be undone.";
confirmButtonText = "Delete";
confirmIcon = "trash-2";
confirmIconBg = "bg-rose-50";
confirmIconText = "text-rose-500";
confirmButtonClass = "bg-violet-600 hover:bg-violet-700";
confirmAction = () => DeleteRole(id);
confirmHideCancel = false;
showConfirmDialog = true;
}
private async Task HandleConfirmAction()
{
showConfirmDialog = false;
if (confirmAction != null)
await confirmAction();
}
private void CloseConfirmDialog()
{
showConfirmDialog = false;
confirmHideCancel = false;
}
private async Task SaveRole()
{
if (string.IsNullOrWhiteSpace(formName))
{
return;
}
isSaving = true;
roleError = null;
var client = ApiClient.CreateClient();
try
{
if (isEditing)
{
var dto = new UpdateRoleDto { Name = formName, Description = formDescription };
var response = await client.PutAsJsonAsync($"Role/{editingId}", dto);
var res = await response.Content.ReadFromJsonAsync<Result>(Serialization.CaseInsensitive);
if (res == null || !res.IsSuccess) { roleError = res?.ErrorMessage ?? ResultMessages.FailedToUpdateRole; return; }
}
else
{
NormalizeAccessSelection(formAccessItems, formSelectedAccessCodes);
var dto = new CreateRoleDto
{
Name = formName,
Description = formDescription,
AccessCodes = formSelectedAccessCodes.ToList()
};
var response = await client.PostAsJsonAsync("Role", dto);
var res = await response.Content.ReadFromJsonAsync<Result<RoleDto>>(Serialization.CaseInsensitive);
if (res == null || !res.IsSuccess) { roleError = res?.ErrorMessage ?? ResultMessages.FailedToCreateRole; return; }
}
CloseRoleModal();
await LoadRoles();
successMessage = isEditing ? "Role updated successfully!" : "Role created successfully!";
}
catch (Exception ex) { roleError = ex.Message; }
finally { isSaving = false; }
}
private async Task DeleteRole(long id)
{
var usersCount = userList.Count(u => u.RoleId == id);
if (usersCount > 0)
{
confirmTitle = "Cannot Delete Role";
confirmMessage = $"you can't delete this role because there are {usersCount} users in this role.";
confirmButtonText = "OK";
confirmIcon = "alert-circle";
confirmIconBg = "bg-rose-50";
confirmIconText = "text-rose-500";
confirmButtonClass = "bg-rose-600 hover:bg-rose-700";
confirmAction = null;
confirmHideCancel = true;
showConfirmDialog = true;
StateHasChanged();
return;
}
var client = ApiClient.CreateClient();
try
{
var response = await client.DeleteAsync($"Role/{id}");
var res = await response.Content.ReadFromJsonAsync<Result>(Serialization.CaseInsensitive);
if (res == null || !res.IsSuccess)
{
confirmTitle = "Cannot Delete Role";
confirmMessage = res?.ErrorMessage ?? "Failed to delete role.";
confirmButtonText = "OK";
confirmIcon = "alert-circle";
confirmIconBg = "bg-rose-50";
confirmIconText = "text-rose-500";
confirmButtonClass = "bg-rose-600 hover:bg-rose-700";
confirmAction = null;
confirmHideCancel = true;
showConfirmDialog = true;
StateHasChanged();
return;
}
await LoadRoles();
if (currentPage > TotalPages && TotalPages > 0)
currentPage = TotalPages;
successMessage = "Role deleted successfully!";
}
catch (Exception ex)
{
Console.WriteLine($"Delete error: {ex.Message}");
confirmTitle = "Error";
confirmMessage = $"Delete error: {ex.Message}";
confirmButtonText = "OK";
confirmIcon = "alert-circle";
confirmIconBg = "bg-rose-50";
confirmIconText = "text-rose-500";
confirmButtonClass = "bg-rose-600 hover:bg-rose-700";
confirmAction = null;
confirmHideCancel = true;
showConfirmDialog = true;
StateHasChanged();
}
}
// ===== Access Management =====
private async Task OpenAccessModal(RoleDto role)
{
selectedRoleId = role.Id;
selectedRoleName = role.Name;
accessSaveError = null;
accessSaveSuccess = null;
accessSearch = "";
isLoadingAccessItems = true;
showAccessModal = true;
StateHasChanged();
var client = ApiClient.CreateClient();
try
{
// Load all available access items with their nested permissions
accessItems = await client.GetFromJsonAsync<List<AccessMenuDto>>("Menu/access-items", Serialization.CaseInsensitive) ?? new();
// Load currently assigned access items for this role
var assignedResult = await client.GetFromJsonAsync<Result<List<string>>>($"Role/{role.Id}/access", Serialization.CaseInsensitive);
selectedAccessCodes = new HashSet<string>(assignedResult?.Value ?? new(), StringComparer.OrdinalIgnoreCase);
NormalizeAccessSelection(accessItems, selectedAccessCodes);
}
catch (Exception ex)
{
Console.WriteLine($"Load access items error: {ex.Message}");
accessSaveError = "Failed to load access items. Please try again.";
}
finally
{
isLoadingAccessItems = false;
StateHasChanged();
}
}
private void CloseAccessModal()
{
showAccessModal = false;
accessItems = new();
selectedAccessCodes = new(StringComparer.OrdinalIgnoreCase);
accessSearch = "";
accessSaveError = null;
accessSaveSuccess = null;
}
private void ToggleSelectAllAccess(ChangeEventArgs e)
{
var selectAll = (bool)(e.Value ?? false);
if (selectAll)
{
selectedAccessCodes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var menu in accessItems)
{
selectedAccessCodes.Add(menu.MenuCode);
foreach (var action in menu.Permissions)
{
selectedAccessCodes.Add(action.PermissionCode);
}
}
}
else
{
selectedAccessCodes.Clear();
}
}
private async Task SaveAccess()
{
isSavingAccess = true;
accessSaveError = null;
accessSaveSuccess = null;
var client = ApiClient.CreateClient();
try
{
NormalizeAccessSelection(accessItems, selectedAccessCodes);
var dto = new AssignAccessDto { AccessCodes = selectedAccessCodes.ToList() };
var response = await client.PostAsJsonAsync($"Role/{selectedRoleId}/access", dto);
if (response.IsSuccessStatusCode)
{
accessSaveSuccess = $"Access for '{selectedRoleName}' saved successfully!";
}
else
{
var body = await response.Content.ReadAsStringAsync();
if (string.IsNullOrWhiteSpace(body))
{
accessSaveError = $"Failed to save access. Server returned {(int)response.StatusCode}.";
}
else
{
try
{
using var doc = System.Text.Json.JsonDocument.Parse(body);
accessSaveError = doc.RootElement.TryGetProperty("message", out var message)
? message.GetString()
: body;
}
catch
{
accessSaveError = body;
}
}
}
}
catch (Exception ex)
{
accessSaveError = ex.Message;
}
finally
{
isSavingAccess = false;
StateHasChanged();
}
}
}