Spaces:
Running
Running
| @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(); | |
| } | |
| } | |
| } | |