[CLAUDE] Phase1.2: CRUD Master + Permission Matrix + FE admin pages
Backend:
- Domain/Master: Supplier (+ SupplierType 5 loai), Project, Department (AuditableEntity)
- Domain/Identity: MenuItem, Permission, MenuKeys const (12 menu)
- EF Configurations voi unique Code + query filter IsDeleted
- DbSets + IApplicationDbContext interface update
- Application: PagedResult + PagedRequest generic
- Application/Master CQRS CRUD 3 entity (Create/Update/Delete/Get/List voi paging search sort)
- Application/Permissions: GetMyMenuTree (union OR role, filter tree), ListMenuItems, ListPermissionsByRole, UpsertPermission (guard admin khong tu giam quyen), ListRoles
- Api/Authorization: MenuPermissionRequirement + Handler (Admin bypass, query DB)
- Program.cs: register 48 policy {menu}.{action} tu MenuKeys x Actions
- Api/Controllers: Suppliers, Projects, Departments, Menus, Roles, Permissions
- DbInitializer: seed 12 menu + admin full CRUD permissions
- Migration AddMasterData + AddPermissions
Frontend (fe-admin):
- Types: menuKeys.ts const, menu.ts (MenuNode/Role/Permission), master.ts (Supplier/Project/Department + SupplierType const-object)
- AuthContext: load menu from /menus/me, cache localStorage, refreshMenu()
- usePermission hook + PermissionGuard component (wrap button)
- UI kit them: Dialog (modal overlay), Textarea, Select
- Generic: DataTable (column config, sortable, loading, empty) + Pagination
- PageHeader component
- apiError helper extract message tu ProblemDetails
- Layout rewrite: render menu dong tu AuthContext.menu (MenuGroup collapsible + NavLink + lucide icon map)
- Pages: master/Suppliers, master/Projects, master/Departments (CRUD + search + sort + paging + Dialog form)
- Page system/Permissions: ma tran Role x MenuKey x CRUD checkbox (tick tu dong PUT upsert)
- App.tsx them 4 route moi
Bug fix:
- MenuPermissionHandler: EF expression tree khong support switch expression -> tach switch ra ngoai AnyAsync
- TS erasableSyntaxOnly khong cho enum -> SupplierType const-object pattern (typeof[keyof])
E2E verified via Vite proxy:
- GET /menus/me -> 6 root + 6 child nodes (12 menus)
- GET /roles -> 12 roles
- POST/GET/PUT/DELETE /suppliers -> full CRUD, soft delete OK
- tsc -b fe-admin pass
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -0,0 +1,35 @@
|
||||
namespace SolutionErp.Application.Permissions.Dtos;
|
||||
|
||||
public record MenuNodeDto(
|
||||
string Key,
|
||||
string Label,
|
||||
string? ParentKey,
|
||||
int Order,
|
||||
string? Icon,
|
||||
bool CanRead,
|
||||
bool CanCreate,
|
||||
bool CanUpdate,
|
||||
bool CanDelete,
|
||||
List<MenuNodeDto> Children);
|
||||
|
||||
public record PermissionDto(
|
||||
Guid Id,
|
||||
Guid RoleId,
|
||||
string MenuKey,
|
||||
bool CanRead,
|
||||
bool CanCreate,
|
||||
bool CanUpdate,
|
||||
bool CanDelete);
|
||||
|
||||
public record RoleDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string? Description,
|
||||
DateTime CreatedAt);
|
||||
|
||||
public record MenuItemDto(
|
||||
string Key,
|
||||
string Label,
|
||||
string? ParentKey,
|
||||
int Order,
|
||||
string? Icon);
|
||||
@ -0,0 +1,121 @@
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SolutionErp.Application.Common.Exceptions;
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
using SolutionErp.Application.Permissions.Dtos;
|
||||
using SolutionErp.Domain.Identity;
|
||||
|
||||
namespace SolutionErp.Application.Permissions;
|
||||
|
||||
// ========== List menu items (for matrix page) ==========
|
||||
|
||||
public record ListMenuItemsQuery : IRequest<List<MenuItemDto>>;
|
||||
|
||||
public class ListMenuItemsQueryHandler(IApplicationDbContext db) : IRequestHandler<ListMenuItemsQuery, List<MenuItemDto>>
|
||||
{
|
||||
public async Task<List<MenuItemDto>> Handle(ListMenuItemsQuery request, CancellationToken ct)
|
||||
{
|
||||
return await db.MenuItems.AsNoTracking()
|
||||
.OrderBy(m => m.Order)
|
||||
.Select(m => new MenuItemDto(m.Key, m.Label, m.ParentKey, m.Order, m.Icon))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== List permissions by role (matrix edit) ==========
|
||||
|
||||
public record ListPermissionsByRoleQuery(Guid RoleId) : IRequest<List<PermissionDto>>;
|
||||
|
||||
public class ListPermissionsByRoleQueryHandler(IApplicationDbContext db) : IRequestHandler<ListPermissionsByRoleQuery, List<PermissionDto>>
|
||||
{
|
||||
public async Task<List<PermissionDto>> Handle(ListPermissionsByRoleQuery request, CancellationToken ct)
|
||||
{
|
||||
return await db.Permissions.AsNoTracking()
|
||||
.Where(p => p.RoleId == request.RoleId)
|
||||
.Select(p => new PermissionDto(p.Id, p.RoleId, p.MenuKey, p.CanRead, p.CanCreate, p.CanUpdate, p.CanDelete))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Upsert permission (admin update matrix) ==========
|
||||
|
||||
public record UpsertPermissionCommand(
|
||||
Guid RoleId,
|
||||
string MenuKey,
|
||||
bool CanRead,
|
||||
bool CanCreate,
|
||||
bool CanUpdate,
|
||||
bool CanDelete) : IRequest;
|
||||
|
||||
public class UpsertPermissionCommandValidator : AbstractValidator<UpsertPermissionCommand>
|
||||
{
|
||||
public UpsertPermissionCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.RoleId).NotEmpty();
|
||||
RuleFor(x => x.MenuKey).NotEmpty().MaximumLength(50);
|
||||
}
|
||||
}
|
||||
|
||||
public class UpsertPermissionCommandHandler(
|
||||
IApplicationDbContext db,
|
||||
RoleManager<Role> roleManager,
|
||||
ICurrentUser currentUser) : IRequestHandler<UpsertPermissionCommand>
|
||||
{
|
||||
public async Task Handle(UpsertPermissionCommand request, CancellationToken ct)
|
||||
{
|
||||
var role = await roleManager.FindByIdAsync(request.RoleId.ToString())
|
||||
?? throw new NotFoundException("Role", request.RoleId);
|
||||
|
||||
// Guard: không cho tự xóa quyền của role Admin (nếu người đang edit là Admin)
|
||||
if (role.Name == AppRoles.Admin && currentUser.Roles.Contains(AppRoles.Admin))
|
||||
{
|
||||
if (!(request.CanRead && request.CanCreate && request.CanUpdate && request.CanDelete))
|
||||
throw new ForbiddenException("Không thể giảm quyền của role Admin khi bạn đang là Admin.");
|
||||
}
|
||||
|
||||
var menu = await db.MenuItems.FirstOrDefaultAsync(m => m.Key == request.MenuKey, ct)
|
||||
?? throw new NotFoundException("MenuItem", request.MenuKey);
|
||||
|
||||
var existing = await db.Permissions.FirstOrDefaultAsync(
|
||||
p => p.RoleId == request.RoleId && p.MenuKey == request.MenuKey, ct);
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
db.Permissions.Add(new Permission
|
||||
{
|
||||
RoleId = request.RoleId,
|
||||
MenuKey = request.MenuKey,
|
||||
CanRead = request.CanRead,
|
||||
CanCreate = request.CanCreate,
|
||||
CanUpdate = request.CanUpdate,
|
||||
CanDelete = request.CanDelete,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.CanRead = request.CanRead;
|
||||
existing.CanCreate = request.CanCreate;
|
||||
existing.CanUpdate = request.CanUpdate;
|
||||
existing.CanDelete = request.CanDelete;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== List all roles ==========
|
||||
|
||||
public record ListRolesQuery : IRequest<List<RoleDto>>;
|
||||
|
||||
public class ListRolesQueryHandler(RoleManager<Role> roleManager) : IRequestHandler<ListRolesQuery, List<RoleDto>>
|
||||
{
|
||||
public async Task<List<RoleDto>> Handle(ListRolesQuery request, CancellationToken ct)
|
||||
{
|
||||
return await roleManager.Roles
|
||||
.OrderBy(r => r.Name)
|
||||
.Select(r => new RoleDto(r.Id, r.Name!, r.Description, r.CreatedAt))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,72 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SolutionErp.Application.Common.Exceptions;
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
using SolutionErp.Application.Permissions.Dtos;
|
||||
using SolutionErp.Domain.Identity;
|
||||
|
||||
namespace SolutionErp.Application.Permissions.Queries.GetMyMenuTree;
|
||||
|
||||
public record GetMyMenuTreeQuery : IRequest<List<MenuNodeDto>>;
|
||||
|
||||
public class GetMyMenuTreeQueryHandler(
|
||||
IApplicationDbContext db,
|
||||
ICurrentUser currentUser,
|
||||
UserManager<User> userManager,
|
||||
RoleManager<Role> roleManager) : IRequestHandler<GetMyMenuTreeQuery, List<MenuNodeDto>>
|
||||
{
|
||||
public async Task<List<MenuNodeDto>> Handle(GetMyMenuTreeQuery request, CancellationToken ct)
|
||||
{
|
||||
if (!currentUser.IsAuthenticated || currentUser.UserId is null)
|
||||
throw new UnauthorizedException();
|
||||
|
||||
var user = await userManager.FindByIdAsync(currentUser.UserId.Value.ToString())
|
||||
?? throw new UnauthorizedException();
|
||||
var roleNames = await userManager.GetRolesAsync(user);
|
||||
|
||||
var roleIds = new List<Guid>();
|
||||
foreach (var name in roleNames)
|
||||
{
|
||||
var role = await roleManager.FindByNameAsync(name);
|
||||
if (role is not null) roleIds.Add(role.Id);
|
||||
}
|
||||
|
||||
var menus = await db.MenuItems.AsNoTracking().OrderBy(m => m.Order).ToListAsync(ct);
|
||||
var perms = await db.Permissions.AsNoTracking()
|
||||
.Where(p => roleIds.Contains(p.RoleId))
|
||||
.ToListAsync(ct);
|
||||
|
||||
// Union CRUD flags qua các role
|
||||
var resolved = perms
|
||||
.GroupBy(p => p.MenuKey)
|
||||
.ToDictionary(g => g.Key, g => (
|
||||
Read: g.Any(p => p.CanRead),
|
||||
Create: g.Any(p => p.CanCreate),
|
||||
Update: g.Any(p => p.CanUpdate),
|
||||
Delete: g.Any(p => p.CanDelete)));
|
||||
|
||||
// Build tree
|
||||
List<MenuNodeDto> BuildChildren(string? parentKey) => menus
|
||||
.Where(m => m.ParentKey == parentKey)
|
||||
.Select(m =>
|
||||
{
|
||||
var flags = resolved.TryGetValue(m.Key, out var f) ? f : (false, false, false, false);
|
||||
return new MenuNodeDto(m.Key, m.Label, m.ParentKey, m.Order, m.Icon,
|
||||
flags.Item1, flags.Item2, flags.Item3, flags.Item4,
|
||||
BuildChildren(m.Key));
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var tree = BuildChildren(null);
|
||||
|
||||
// Filter: chỉ trả về node có CanRead=true (hoặc có child CanRead=true)
|
||||
static bool HasAccess(MenuNodeDto n) => n.CanRead || n.Children.Any(HasAccess);
|
||||
List<MenuNodeDto> Filter(List<MenuNodeDto> nodes) => nodes
|
||||
.Where(HasAccess)
|
||||
.Select(n => n with { Children = Filter(n.Children) })
|
||||
.ToList();
|
||||
|
||||
return Filter(tree);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user