KIM-JINO5 4 luni în urmă
părinte
comite
b2b74ba841
56 a modificat fișierele cu 1688 adăugiri și 7 ștergeri
  1. 96 0
      Admin/Pages/Director/Role/Index.cshtml
  2. 71 0
      Admin/Pages/Director/Role/Index.cshtml.cs
  3. 115 0
      Admin/Pages/Director/Role/Permission.cshtml
  4. 68 0
      Admin/Pages/Director/Role/Permission.cshtml.cs
  5. 98 0
      Admin/Pages/Director/User/Edit.cshtml
  6. 69 0
      Admin/Pages/Director/User/Edit.cshtml.cs
  7. 91 0
      Admin/Pages/Director/User/Index.cshtml
  8. 18 0
      Admin/Pages/Director/User/Index.cshtml.cs
  9. 75 0
      Admin/Pages/Director/User/Roles.cshtml
  10. 59 0
      Admin/Pages/Director/User/Roles.cshtml.cs
  11. 2 2
      Admin/Pages/Shared/Layout/LayoutDataProvider.cs
  12. 1 1
      Admin/Pages/Shared/Layout/LayoutViewModel.cs
  13. 1 1
      Admin/Pages/Shared/_MenuItem.cshtml
  14. 0 2
      Admin/wwwroot/js/site.js
  15. 10 0
      Application/Abstractions/Identity/IIdentityRoleReader.cs
  16. 11 0
      Application/Abstractions/Identity/IIdentityRoleWriter.cs
  17. 10 0
      Application/Abstractions/Identity/IIdentityUserReader.cs
  18. 10 0
      Application/Abstractions/Identity/IIdentityUserWriter.cs
  19. 15 0
      Application/Abstractions/Identity/Models/ApplicationUserDto.cs
  20. 24 0
      Application/Abstractions/Identity/Models/AspNetUserDto.cs
  21. 18 0
      Application/Abstractions/Identity/Models/PermissionDto.cs
  22. 9 0
      Application/Abstractions/Identity/Models/RoleDto.cs
  23. 13 0
      Application/Abstractions/Identity/Models/UserRolesDto.cs
  24. 6 0
      Application/Features/Director/CreateRole/Command.cs
  25. 13 0
      Application/Features/Director/CreateRole/Handler.cs
  26. 6 0
      Application/Features/Director/DeleteRole/Command.cs
  27. 13 0
      Application/Features/Director/DeleteRole/Handler.cs
  28. 66 0
      Application/Features/Director/GetRolePermissions/Handler.cs
  29. 6 0
      Application/Features/Director/GetRolePermissions/Query.cs
  30. 29 0
      Application/Features/Director/GetRolePermissions/Response.cs
  31. 20 0
      Application/Features/Director/GetRoles/Handler.cs
  32. 6 0
      Application/Features/Director/GetRoles/Query.cs
  33. 9 0
      Application/Features/Director/GetRoles/Response.cs
  34. 14 0
      Application/Features/Director/GetUser/Handler.cs
  35. 7 0
      Application/Features/Director/GetUser/Query.cs
  36. 46 0
      Application/Features/Director/GetUser/Response.cs
  37. 30 0
      Application/Features/Director/GetUserRoles/Handler.cs
  38. 6 0
      Application/Features/Director/GetUserRoles/Query.cs
  39. 20 0
      Application/Features/Director/GetUserRoles/Response.cs
  40. 25 0
      Application/Features/Director/GetUsers/Handler.cs
  41. 6 0
      Application/Features/Director/GetUsers/Query.cs
  42. 15 0
      Application/Features/Director/GetUsers/Response.cs
  43. 10 0
      Application/Features/Director/UpdateRolePermissions/Command.cs
  44. 13 0
      Application/Features/Director/UpdateRolePermissions/Handler.cs
  45. 16 0
      Application/Features/Director/UpdateUser/Command.cs
  46. 25 0
      Application/Features/Director/UpdateUser/Handler.cs
  47. 11 0
      Application/Features/Director/UpdateUserRoles/Command.cs
  48. 13 0
      Application/Features/Director/UpdateUserRoles/Handler.cs
  49. 6 0
      Infrastructure/DependencyInjection.cs
  50. 22 0
      Infrastructure/Persistence/Identity/ApplicationUser.cs
  51. 52 0
      Infrastructure/Persistence/Identity/IdentityRoleReader.cs
  52. 96 0
      Infrastructure/Persistence/Identity/IdentityRoleWriter.cs
  53. 86 0
      Infrastructure/Persistence/Identity/IdentityUserReader.cs
  54. 109 0
      Infrastructure/Persistence/Identity/IdentityUserWriter.cs
  55. 1 1
      SharedKernel/Constants/Menus.cs
  56. 1 0
      SharedKernel/SharedKernel.csproj

+ 96 - 0
Admin/Pages/Director/Role/Index.cshtml

@@ -0,0 +1,96 @@
+@page
+@model Admin.Pages.Director.Role.IndexModel
+@{
+    ViewData["Title"] = "역할 관리";
+}
+
+<div class="container">
+    <div class="row">
+        <div class="col">
+            <h3>@ViewData["Title"]</h3>
+        </div>
+        <div class="col text-end align-self-center">
+            <a asp-page="/Director/User/Index" class="btn btn-sm btn-secondary">취소</a>
+        </div>
+    </div>
+    <hr />
+
+    <div asp-validation-summary="ModelOnly" class="text-danger"></div>
+    <partial name="_StatusMessage" />
+
+    <form name="f_admin_write" id="fAdminWrite" method="post" accept-charset="utf-8" autocomplete="off">
+        <div class="input-group mb-3">
+            <label class="input-group-text" for="roleName">Role:</label>
+            <input asp-for="RoleName" class="form-control" placeholder="역할(Role)을 입력해주세요." required maxlength="100"/>
+            <button type="submit" class="btn btn-sm btn-success">역할 추가</button>
+        </div>
+    </form>
+
+    <!-- 삭제를 위한 -->
+    <form id="deleteRoleForm" method="post" accept-charset="utf-8" asp-page-handler="Delete">
+        @Html.AntiForgeryToken()
+        <input type="hidden" id="deleteRoleID" name="id" />
+    </form>
+
+    <div class="table-responsive">
+        <table class="table table-bordered table-striped table-hover mt-4">
+            <thead>
+                <tr class="text-center">
+                    <th>ID</th>
+                    <th>Role Name</th>
+                    <th>Permission</th>
+                    <th>Actions</th>
+                </tr>
+            </thead>
+            <tbody>
+                @if (Model.List == null || !Model.List.Any())
+                {
+                    <tr>
+                        <td colspan="4">No Data.</td>
+                    </tr>
+                }
+                else
+                {
+                    @foreach (var role in Model.List)
+                    {
+                        <tr>
+                            <td>@role.ID</td>
+                            <td>@role.Name</td>
+                            <td>@role.ClaimsCount</td>
+                            <td>
+                                <button type="button" class="btn btn-sm btn-danger btn-row-delete" data-id="@role.ID" data-name="@role.Name"> 삭제 </button>
+                                <a class="btn btn-sm btn-primary" asp-page="/Director/Role/Permission" asp-route-id="@role.ID">권한 관리</a>
+                            </td>
+                        </tr>
+                    }
+                }
+            </tbody>
+        </table>
+    </div>
+</div>
+
+@section Scripts {
+    @{
+        await Html.RenderPartialAsync("_ValidationScriptsPartial");
+    }
+
+    <script>
+        document.addEventListener("click", function(e) {
+            const btn = e.target.closest('.btn-row-delete');
+            if (!btn) {
+                return;
+            }
+
+            const id = btn.dataset.id;
+            const name = btn.dataset.name;
+
+            const label = name ? `역할 '${name}'` : '해당 역할';
+            if (!confirm(`${label}을(를) 삭제하시겠습니까?`)) {
+                return;
+            }
+
+            document.getElementById('deleteRoleID').value = id;
+            document.getElementById('deleteRoleForm').submit();
+        });
+    </script>
+}

+ 71 - 0
Admin/Pages/Director/Role/Index.cshtml.cs

@@ -0,0 +1,71 @@
+using Application.Features.Director.CreateRole;
+using Application.Features.Director.DeleteRole;
+using Application.Features.Director.GetRoles;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Director.Role
+{
+    public class IndexModel(IMediator mediator) : PageModel
+    {
+        public int Total { get; set; } = 0;
+        public List<Response> List { get; set; } = [];
+
+        [Required]
+        [DisplayName("역할(Role)")]
+        [MaxLength(100)]
+        [BindProperty]
+        public string? RoleName { get; set; }
+
+        public async Task OnGetAsync(CancellationToken ct)
+        {
+            List = await mediator.Send(new GetAllRolesQuery(), ct);
+            Total = List.Count;
+        }
+
+        public async Task<IActionResult> OnPostAsync(CancellationToken ct)
+        {
+            try
+            {
+                if (!ModelState.IsValid)
+                {
+                    throw new Exception("유효성 검사에 실패하였습니다.");
+                }
+
+                var command = new CreateRoleCommand(
+                    RoleName: RoleName
+                );
+
+                await mediator.Send(command, ct);
+
+                TempData["SuccessMessage"] = $"{RoleName} 역할이 추가되었습니다.";
+
+                return RedirectToPage();
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+
+                return Page();
+            }
+        }
+
+        public async Task<IActionResult> OnPostDeleteAsync(string id, CancellationToken ct)
+        {
+            try
+            {
+                await mediator.Send(new DeleteRoleCommand(id), ct);
+                TempData["SuccessMessage"] = "정상적으로 삭제되었습니다.";
+                return RedirectToPage();
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+                return RedirectToPage();
+            }
+        }
+    }
+}

+ 115 - 0
Admin/Pages/Director/Role/Permission.cshtml

@@ -0,0 +1,115 @@
+@page
+@model Admin.Pages.Director.Role.PermissionModel
+@{
+    ViewData["Title"] = $"{Model.Role.RoleName}의 권한";
+}
+
+<div class="container">
+    <div asp-validation-summary="ModelOnly" class="text-danger"></div>
+    <partial name="_StatusMessage" />
+
+    <form name="f_admin_write" id="fAdminWrite" method="post" accept-charset="utf-8" autocomplete="off">
+        <input type="hidden" asp-for="Role.RoleID" />
+        <input type="hidden" asp-for="Role.RoleName" />
+        <div asp-validation-summary="All" class="text-danger"></div>
+
+        <div class="row">
+            <div class="col">
+                <h2>@ViewData["Title"]</h2>
+                권한을 추가하거나 회수할 수 있습니다. <ins>Create: 읽기, View: 열람, Edit: 수정, Delete: 삭제</ins>
+            </div>
+            <div class="col-auto align-self-end">
+                <button type="submit" class="btn btn-sm btn-success">저장</button>
+                <a asp-page="/Director/Role/Index" class="btn btn-sm btn-secondary">취소</a>
+            </div>
+        </div>
+
+        <hr />
+
+        <div class="flex">
+            @if (Model.Role != null)
+            {
+                @for (int i = 0; i < Model.Role.RoleClaims.Count; i++)
+                {
+                    var group = Model.Role.RoleClaims[i];
+                    var isAllChecked = group.IsAllSelected ? "checked" : "";
+                    var IsPartialSelected = group.IsPartialSelected ? "data-indeterminate=\"true\"" : "";
+
+                    <div class="mb-2 border p-3 rounded shadow-sm">
+                        <div class="form-check">
+                            <input type="checkbox" id="grp-@i" class="form-check-input group-toggle mr-1" data-group="@group.GroupName" @isAllChecked @IsPartialSelected />
+                            <label for="grp-@i" class="font-semibold mb-2">
+                                @group.GroupName 전체 선택
+                            </label>
+                        </div>
+
+
+                        <div class="row row-cols-1 row-cols-sm-2 row-cols-md-4">
+                            @for (int j = 0; j < group.Permissions.Count; j++)
+                            {
+                                var perm = group.Permissions[j].DisplayValue?.Split('.') ?? Array.Empty<string>();
+
+                                var menuName = perm.Length > 1 ? perm[1] : string.Empty;
+                                var menuID = perm.Length > 2 ? perm[2] : string.Empty;
+                                var permission = perm.Length > 3 ? perm[3] : string.Empty;
+
+                                <div class="col">
+                                    <div class="form-check m-1">
+                                        <input type="hidden" name="Role.RoleClaims[@i].Permissions[@j].DisplayValue" value="@group.Permissions[j].DisplayValue" />
+                                        <input type="hidden" name="Role.RoleClaims[@i].GroupName" value="@group.GroupName" />
+
+                                        <input type="checkbox"
+                                               name="Role.RoleClaims[@i].Permissions[@j].IsSelected"
+                                               value="true"
+                                               class="form-check-input perm-checkbox"
+                                               data-group="@group.GroupName"
+                                               id="chk-@i-@j"
+                                               @(group.Permissions[j].IsSelected ? "checked" : "") />
+
+                                        <label class="form-check-label" for="chk-@i-@j">
+                                            @menuName - <span class="badge text-bg-light border">@permission</span>
+                                        </label>
+                                    </div>
+                                </div>
+                            }
+                        </div>
+                    </div>
+                    }
+                } else {
+                    <div class="alert alert-warning">권한 정보가 없습니다.</div>
+                }
+            </div>
+
+        <div class="card-footer text-center p-4">
+            <button type="submit" class="btn btn-sm btn-success">저장</button>
+            <a asp-page="/Director/Role/Index" class="btn btn-sm btn-secondary">취소</a>
+        </div>
+
+        <br />
+    </form>
+</div>
+
+@section Scripts {
+    @{
+        await Html.RenderPartialAsync("_ValidationScriptsPartial");
+    }
+
+    <script>
+        document.querySelectorAll('.group-toggle').forEach(groupCheckbox => {
+             if (groupCheckbox.dataset.indeterminate === "true") {
+                 groupCheckbox.indeterminate = true;
+             }
+
+             groupCheckbox.addEventListener('change', (e) => {
+                 const group = e.target.dataset.group;
+                 const isChecked = e.target.checked;
+
+                 console.log(group);
+
+                 document.querySelectorAll(`.perm-checkbox[data-group="${group}"]`).forEach(cb => {
+                     cb.checked = isChecked;
+                 });
+             });
+         });
+    </script>
+}

+ 68 - 0
Admin/Pages/Director/Role/Permission.cshtml.cs

@@ -0,0 +1,68 @@
+using Application.Abstractions.Identity.Models;
+using Application.Features.Director.GetRolePermissions;
+using Application.Features.Director.UpdateRolePermissions;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace Admin.Pages.Director.Role
+{
+    public class PermissionModel(IMediator mediator) : PageModel
+    {
+        [BindProperty]
+        public Response Role { get; set; } = new() { RoleID = string.Empty };
+
+        public async Task<IActionResult> OnGetAsync(string id, CancellationToken ct)
+        {
+            if (string.IsNullOrEmpty(id))
+            {
+                TempData["ErrorMessages"] = "유효하지 않은 접근입니다.";
+                return RedirectToPage("/Director/Role/Index");
+            }
+
+            Role = await mediator.Send(new GetRoleClaimsQuery(id), ct);
+
+            return Page();
+        }
+
+        public async Task<IActionResult> OnPostAsync(CancellationToken ct)
+        {
+            try
+            {
+                if (!ModelState.IsValid)
+                {
+                    throw new Exception("유효성 검사에 실패하였습니다.");
+                }
+
+                var permissions = new PermissionDto
+                {
+                    RoleClaims = [.. Role.RoleClaims.Select(g => new PermissionDto.PermissionGroup
+                    {
+                        Permissions = [.. g.Permissions.Select(p => new PermissionDto.PermissionGroup.Checkbox
+                        {
+                            DisplayValue = p.DisplayValue,
+                            IsSelected = p.IsSelected
+                        })]
+                    })]
+                };
+
+                var command = new UpdateRolePermissionsCommand(
+                    Role.RoleID,
+                    permissions
+                );
+
+                await mediator.Send(command, ct);
+
+                TempData["SuccessMessage"] = $"{Role.RoleName} 권한 정보가 수정되었습니다.";
+
+                return RedirectToPage("/Director/Role/Index", new { id = Role.RoleID });
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+
+                return Page();
+            }
+        }
+    }
+}

+ 98 - 0
Admin/Pages/Director/User/Edit.cshtml

@@ -0,0 +1,98 @@
+@page
+@model Admin.Pages.Director.User.EditModel
+@{
+    ViewData["Title"] = "관리자 정보 수정";
+}
+
+<div class="container">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <div asp-validation-summary="ModelOnly" class="text-danger"></div>
+    <partial name="_StatusMessage" />
+    <small>관리자 가입 회원들입니다. 메뉴 접근 권한을 관리할 수 있습니다.</small>
+
+    <form name="f_admin_write" id="fAdminWrite" class="mt-3" method="post" accept-charset="utf-8" autocomplete="off">
+        <input type="hidden" asp-for="Input.ID" />
+
+        <div class="row mb-2">
+            <label asp-for="Input.Email" class="col-sm-2 col-form-label"><span class="text-danger">*</span> E-mail</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Email" class="form-control" />
+                <span asp-validation-for="Input.Email" class="text-danger"></span>
+                <small class="form-text text-muted">중복 시 변경이 불가합니다.</small>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Input.Name" class="col-sm-2 col-form-label"><span class="text-danger">*</span> 이름</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Name" class="form-control" />
+                <span asp-validation-for="Input.Name" class="text-danger"></span>
+                <small class="form-text text-muted">실명을 입력해주세요.</small>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Input.Phone" class="col-sm-2 col-form-label">연락처</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Phone" class="form-control" />
+                <span asp-validation-for="Input.Phone" class="text-danger"></span>
+                <small class="form-text text-muted">010-XXXX-XXXX 형식으로 입력해주세요.</small>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Input.NewPassword" class="col-sm-2 col-form-label">새 비밀번호</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.NewPassword" class="form-control" autocomplete="new-password" aria-required="true" />
+                <span asp-validation-for="Input.NewPassword" class="text-danger"></span>
+                <div class="form-text text-muted">
+                    소문자, 대문자, 특수문자, 숫자를 각 1개 이상은 포함되어야 변경이 가능합니다.
+                </div>
+            </div>
+        </div>
+        <div class="row mb-3">
+            <label asp-for="Input.ConfirmPassword" class="col-sm-2 col-form-label">새 비밀번호 재입력</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.ConfirmPassword" class="form-control" autocomplete="new-password" aria-required="true" />
+                <span asp-validation-for="Input.ConfirmPassword" class="text-danger"></span>
+            </div>
+        </div>
+        <div class="row">
+            <label asp-for="Input.IsDeleted" class="col-sm-2">비활성화</label>
+            <div class="col-sm-10">
+                <div class="form-check">
+                    <input type="checkbox" asp-for="Input.IsDeleted" class="form-check-input" />
+                    <label class="form-check-label" asp-for="Input.IsDeleted">계정을 폐쇄합니다.</label>
+                </div>
+            </div>
+        </div>
+        <div class="row">
+            <label asp-for="Input.EmailConfirmed" class="col-sm-2">이메일 인증 여부</label>
+            <div class="col-sm-10">
+                <div class="form-check">
+                    <input type="checkbox" asp-for="Input.EmailConfirmed" class="form-check-input" />
+                    <label class="form-check-label" asp-for="Input.EmailConfirmed">이메일 인증을 통과하였습니다.</label>
+                </div>
+            </div>
+        </div>
+        <div class="row">
+            <label asp-for="Input.LockoutEnd" class="col-sm-2">로그인 차단 여부</label>
+            <div class="col-sm-10">
+                <div class="form-check">
+                    <input type="checkbox" asp-for="Input.LockoutEnd" class="form-check-input" />
+                    <label class="form-check-label" asp-for="Input.LockoutEnd">로그인을 못하게 막습니다.</label>
+                </div>
+            </div>
+        </div>
+        <hr />
+        <div class="d-grid gap-2 text-center d-md-block">
+            <button type="submit" class="btn btn-sm btn-success">저장</button>
+            <a asp-page="/Director/User/Index" class="btn btn-sm btn-secondary">취소</a>
+        </div>
+    </form>
+</div>
+
+@section Scripts {
+    @{
+        await Html.RenderPartialAsync("_ValidationScriptsPartial");
+    }
+}

+ 69 - 0
Admin/Pages/Director/User/Edit.cshtml.cs

@@ -0,0 +1,69 @@
+using Application.Features.Director.GetUser;
+using Application.Features.Director.UpdateUser;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace Admin.Pages.Director.User
+{
+    public class EditModel(IMediator mediator) : PageModel
+    {
+        [BindProperty]
+        public Response? Input { get; set; }
+
+        public async Task OnGetAsync(string id, CancellationToken ct)
+        {
+            var user = await mediator.Send(new GetUserQuery(id), ct);
+
+            if (user != null)
+            {
+                Input = new Response
+                {
+                    ID = user.ID,
+                    Name = user.FullName,
+                    Email = user.Email,
+                    Phone = user.PhoneNumber,
+                    IsDeleted = user.IsDeleted,
+                    EmailConfirmed = user.EmailConfirmed,
+                    LockoutEnd = user.LockoutEnd.HasValue,
+                    Roles = user.Roles
+                };
+            }
+        }
+
+        public async Task<IActionResult> OnPostAsync(CancellationToken ct)
+        {
+            try
+            {
+                if (!ModelState.IsValid)
+                {
+                    throw new Exception("유효성 검사에 실패하였습니다.");
+                }
+
+                var command = new UpdateUserCommand(
+                    ID: Input!.ID,
+                    FullName: Input.Name,
+                    Email: Input.Email,
+                    NewPassword: Input.NewPassword,
+                    ConfirmPassword: Input.ConfirmPassword,
+                    PhoneNumber: Input.Phone,
+                    IsDeleted: Input.IsDeleted,
+                    EmailConfirmed: Input.EmailConfirmed,
+                    LockoutEnd: Input.LockoutEnd
+                );
+
+                await mediator.Send(command, ct);
+
+                TempData["SuccessMessage"] = "사용자 정보가 정상적으로 수정되었습니다.";
+
+                return RedirectToPage(command);
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+
+                return Page();
+            }
+        }
+    }
+}

+ 91 - 0
Admin/Pages/Director/User/Index.cshtml

@@ -0,0 +1,91 @@
+@page
+@model Admin.Pages.Director.User.IndexModel
+@{
+    ViewData["Title"] = "관리자";
+}
+
+<div class="container">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <partial name="_StatusMessage" />
+    <small>관리자 가입 회원들입니다. 메뉴 접근 권한을 관리할 수 있습니다.</small>
+
+    <div class="row g-2">
+        <div class="col align-self-end">
+            Total : @Model.Total.ToString("N0")
+        </div>
+        <div class="col text-end">
+            <a class="btn btn-sm btn-primary" asp-page="/Director/Role/Index">역할 관리</a>
+        </div>
+    </div>
+
+    <div class="table-responsive">
+        <table class="table table-striped table-bordered table-hover mt-3">
+            <thead>
+                <tr>
+                    <th>ID</th>
+                    <th>Mail</th>
+                    <th>Name</th>
+                    <th>Role</th>
+                    <th>Actions</th>
+                </tr>
+            </thead>
+            <tbody>
+                @if (Model.List == null || !Model.List.Any())
+                {
+                    <tr>
+                        <td colspan="5">No Data.</td>
+                    </tr>
+                }
+                else
+                {
+                    @foreach (var user in Model.List)
+                    {
+                        <tr>
+                            <td>
+                                @user.ID<br/>
+                                @if (user.IsDeleted)
+                                {
+                                    <span class="badge bg-danger text-white">폐쇄</span>
+                                }
+                                @if (user.LockoutEnd)
+                                {
+                                    <span class="badge bg-warning text-white">차단</span>
+                                }
+                                @if (user.EmailConfirmed)
+                                {
+                                    <span class="badge bg-success text-white">인증</span>
+                                }
+                            </td>
+                            <td>@user.Email</td>
+                            <td>@(user.Name ?? "-")</td>
+                            <td>
+                                @if (user.Roles.Any())
+                                {
+                                    @string.Join(", ", user.Roles)
+                                }
+                                else
+                                {
+                                    <span>-</span>
+                                }
+                            </td>
+                            <td>
+                                <div class="d-xl-flex gap-2 justify-content-center d-grid">
+                                    <a class="btn btn-sm btn-info text-white" asp-page="/Director/User/Edit" asp-route-id="@user.ID">수정</a>
+                                    <a class="btn btn-sm btn-danger" asp-page="/Director/User/Roles" asp-route-id="@user.ID">권한</a>
+                                </div>
+                            </td>
+                        </tr>
+                    }
+                }
+            </tbody>
+        </table>
+    </div>
+</div>
+
+@section Scripts {
+    @{
+        await Html.RenderPartialAsync("_ValidationScriptsPartial");
+    }
+}

+ 18 - 0
Admin/Pages/Director/User/Index.cshtml.cs

@@ -0,0 +1,18 @@
+using Application.Features.Director.GetUsers;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using MediatR;
+
+namespace Admin.Pages.Director.User
+{
+    public class IndexModel(IMediator mediator) : PageModel
+    {
+        public int Total { get; set; } = 0;
+        public List<Response> List { get; set; } = [];
+
+        public async Task OnGetAsync(CancellationToken ct)
+        {
+            List = await mediator.Send(new GetAllUserQuery(), ct);
+            Total = List.Count;
+        }
+    }
+}

+ 75 - 0
Admin/Pages/Director/User/Roles.cshtml

@@ -0,0 +1,75 @@
+@page
+@model Admin.Pages.Director.User.RolesModel
+@{
+    ViewData["Title"] = "권한 관리";
+}
+
+<div class="container">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <div asp-validation-summary="ModelOnly" class="text-danger"></div>
+    <partial name="_StatusMessage" />
+    <small>사용자에게 권한이 지정된 역할을 부여합니다.</small>
+
+    <form name="f_admin_write" id="fAdminWrite" class="mt-3" method="post" accept-charset="utf-8" autocomplete="off">
+        <input type="hidden" asp-for="Input.User.ID" />
+
+        <h5>@Model.Input!.User.FullName</h5>
+        <hr />
+
+        @if (Model.Input.Roles == null || Model.Input.Roles.Count <= 0)
+        {
+            <div class="text-center">
+                부여된 권한이 없습니다.
+            </div>
+        }
+
+        <div class="form-check">
+            <input class="form-check-input" type="checkbox" value="1" id="allChecked" checked />
+            <label class="form-check-label" for="allChecked">
+                모두 선택
+            </label>
+        </div>
+
+        @if (Model.Input.Roles != null) {
+            @for (int i = 0; i < Model.Input.Roles.Count; i++)
+            {
+                <div class="form-check m-1">
+                    <input type="hidden" asp-for="@Model.Input.Roles[i].DisplayValue" />
+                    <input asp-for="@Model.Input.Roles[i].IsSelected" class="form-check-input" />
+                    <label asp-for="@Model.Input.Roles[i].IsSelected" class="form-check-label">
+                        @Model.Input.Roles[i].DisplayValue
+                    </label>
+                </div>
+            }
+        }
+
+        <div asp-validation-summary="All" class="text-danger"></div>
+        <hr />
+
+        <div class="text-center">
+            @if (Model.Input.Roles != null && Model.Input.Roles.Count >= 0)
+            {
+                <button type="submit" class="btn btn-sm btn-success">저장</button>
+            }
+            <a asp-page="/Director/User/Index" class="btn btn-sm btn-secondary">취소</a>
+        </div>
+    </form>
+</div>
+
+@section Scripts {
+    @{
+        await Html.RenderPartialAsync("_ValidationScriptsPartial");
+    }
+
+    <script>
+        // checkbox 모두 선택/해제
+        document.getElementById("allChecked").addEventListener("change", function () {
+            var checkboxes = document.querySelectorAll("input[type='checkbox']:not(#allChecked)");
+            for (var i = 0; i < checkboxes.length; i++) {
+                checkboxes[i].checked = this.checked;
+            }
+        });
+    </script>
+}

+ 59 - 0
Admin/Pages/Director/User/Roles.cshtml.cs

@@ -0,0 +1,59 @@
+using Application.Abstractions.Identity.Models;
+using Application.Features.Director.GetUserRoles;
+using Application.Features.Director.UpdateUserRoles;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace Admin.Pages.Director.User
+{
+    public class RolesModel(IMediator mediator) : PageModel
+    {
+        [BindProperty]
+        public required Response Input { get; set; }
+
+        public async Task<IActionResult> OnGetAsync(string id, CancellationToken ct)
+        {
+            if (string.IsNullOrEmpty(id))
+            {
+                TempData["ErrorMessages"] = "유효하지 않은 접근입니다.";
+                return RedirectToPage("/Director/User/Index");
+            }
+
+            Input = await mediator.Send(new GetUserRolesQuery(id), ct);
+
+            return Page();
+        }
+
+        public async Task<IActionResult> OnPostAsync(CancellationToken ct)
+        {
+            try
+            {
+                if (!ModelState.IsValid)
+                {
+                    throw new Exception("유효성 검사에 실패하였습니다.");
+                }
+
+                var command = new UpdateUserRolesCommand(
+                    Input!.User.ID,
+                    new UserRolesDto
+                    {
+                        Roles = [.. (Input.Roles ?? []).Select(r => new UserRolesDto.Checkbox { DisplayValue = r.DisplayValue, IsSelected = r.IsSelected })]
+                    }
+                );
+
+                await mediator.Send(command, ct);
+
+                TempData["SuccessMessage"] = "권한이 변경되었습니다.";
+
+                return RedirectToPage(new { id = Input.User.ID });
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+
+                return Page();
+            }
+        }
+    }
+}

+ 2 - 2
Admin/Pages/Shared/Layout/LayoutDataProvider.cs

@@ -1,5 +1,5 @@
 using SharedKernel;
-using Admin.Constants;
+using SharedKernel.Constants;
 using Microsoft.AspNetCore.Authorization;
 using Microsoft.Extensions.Options;
 using Microsoft.AspNetCore.Identity;
@@ -35,7 +35,7 @@ namespace Admin.Pages.Shared.Layout
                 UserName = appUser?.UserName ?? appUser?.Email ?? principal.Identity?.Name ?? string.Empty,
                 Role = principal.FindFirst(ClaimTypes.Role)?.Value ?? principal.FindFirst("role")?.Value ?? string.Empty,
                 AppSettings = _settings,
-                Menus = Menus.GetMenus()
+                Menus = filteredMenus
             };
         }
     }

+ 1 - 1
Admin/Pages/Shared/Layout/LayoutViewModel.cs

@@ -1,4 +1,4 @@
-using Admin.Constants;
+using SharedKernel.Constants;
 using SharedKernel;
 
 namespace Admin.Pages.Shared.Layout

+ 1 - 1
Admin/Pages/Shared/_MenuItem.cshtml

@@ -1,4 +1,4 @@
-@model Admin.Constants.Menu
+@model SharedKernel.Constants.Menu
 @inject Microsoft.AspNetCore.Http.IHttpContextAccessor HttpContextAccessor
 @{
     // 현재 요청 경로 가져오기

+ 0 - 2
Admin/wwwroot/js/site.js

@@ -202,10 +202,8 @@ class ActionButtons {
 
 const actionButtons = new ActionButtons();
 $(document).on("click", "#btnListUpdate", (e) => actionButtons.Update(e));
-$(document).on("click", "#btnListDelete", (e) => actionButtons.Delete(e));
 $(document).on("click", "#btnListRecover", (e) => actionButtons.Recover(e));
 $(document).on("click", "#btnListExecute", (e) => actionButtons.Execute(e));
-$(document).on("click", ".btn-row-delete", () => confirm("정말 삭제하시겠습니까?"));
 $(document).on("click", ".btn-row-execute", () => confirm("정말 처리하시겠습니까?"));
 $(document).on("click", "#checkedAll", (e) => actionButtons.checkedAll(e));
 $(document).on("change", "input.list-check-box", actionButtons.toggleDisabled);

+ 10 - 0
Application/Abstractions/Identity/IIdentityRoleReader.cs

@@ -0,0 +1,10 @@
+using Application.Abstractions.Identity.Models;
+
+namespace Application.Abstractions.Identity
+{
+    public interface IIdentityRoleReader
+    {
+        Task<List<RoleDto>> GetRolesAsync(CancellationToken ct);
+        Task<RoleDto> GetRoleAsync(string roleID, CancellationToken ct);
+    }
+}

+ 11 - 0
Application/Abstractions/Identity/IIdentityRoleWriter.cs

@@ -0,0 +1,11 @@
+using Application.Abstractions.Identity.Models;
+
+namespace Application.Abstractions.Identity
+{
+    public interface IIdentityRoleWriter
+    {
+        Task CreateRoleAsync(string roleName, CancellationToken ct);
+        Task DeleteRoleAsync(string roleID, CancellationToken ct);
+        Task UpdateRoleAsync(string roleID, PermissionDto permission, CancellationToken ct);
+    }
+}

+ 10 - 0
Application/Abstractions/Identity/IIdentityUserReader.cs

@@ -0,0 +1,10 @@
+using Application.Abstractions.Identity.Models;
+
+namespace Application.Abstractions.Identity
+{
+    public interface IIdentityUserReader
+    {
+        Task<AspNetUserDto?> GetUserAsync(string id, CancellationToken ct);
+        Task<List<AspNetUserDto>> GetAllUserAsync(CancellationToken ct);
+    }
+}

+ 10 - 0
Application/Abstractions/Identity/IIdentityUserWriter.cs

@@ -0,0 +1,10 @@
+using Application.Abstractions.Identity.Models;
+
+namespace Application.Abstractions.Identity
+{
+    public interface IIdentityUserWriter
+    {
+        Task UpdateUserAsync(ApplicationUserDto user, CancellationToken ct);
+        Task UpdateUserRolesAsync(string UserID, UserRolesDto? userRoles, CancellationToken ct);
+    }
+}

+ 15 - 0
Application/Abstractions/Identity/Models/ApplicationUserDto.cs

@@ -0,0 +1,15 @@
+namespace Application.Abstractions.Identity.Models
+{
+    public class ApplicationUserDto
+    {
+        public required string ID { get; set; }
+        public string? Name { get; set; }
+        public string? Email { get; set; }
+        public string? Phone { get; set; }
+        public string? NewPassword { get; set; }
+        public string? ConfirmPassword { get; set; }
+        public bool IsDeleted { get; set; } = false;
+        public bool EmailConfirmed { get; set; } = false;
+        public bool LockoutEnd { get; set; } = false;
+    }
+}

+ 24 - 0
Application/Abstractions/Identity/Models/AspNetUserDto.cs

@@ -0,0 +1,24 @@
+namespace Application.Abstractions.Identity.Models
+{
+    public sealed class AspNetUserDto
+    {
+        public required string ID { get; init; }
+        public string? UserName { get; init; }
+        public string? NormalizedUserName { get; init; }
+        public string? Email { get; init; }
+        public string? NormalizedEmail { get; init; }
+        public bool EmailConfirmed { get; init; } = false;
+        public string? PhoneNumber { get; init; }
+        public bool PhoneNumberConfirmed { get; init; } = false;
+        public bool TwoFactorEnabled { get; init; } = false;
+        public DateTimeOffset? LockoutEnd { get; init; }
+        public bool LockoutEnabled { get; init; } = false;
+        public int AccessFailedCount { get; init; }
+
+        // Custom columns (ApplicationUser)
+        public string? FullName { get; init; }
+        public bool IsDeleted { get; init; } = false;
+
+        public IEnumerable<string> Roles { get; set; } = [];
+    }
+}

+ 18 - 0
Application/Abstractions/Identity/Models/PermissionDto.cs

@@ -0,0 +1,18 @@
+namespace Application.Abstractions.Identity.Models
+{
+    public sealed class PermissionDto
+    {
+        public class PermissionGroup
+        {
+            public class Checkbox
+            {
+                public string? DisplayValue { get; set; }
+                public bool IsSelected { get; set; }
+            }
+
+            public List<Checkbox> Permissions { get; set; } = [];
+        }
+
+        public List<PermissionGroup> RoleClaims { get; set; } = [];
+    }
+}

+ 9 - 0
Application/Abstractions/Identity/Models/RoleDto.cs

@@ -0,0 +1,9 @@
+namespace Application.Abstractions.Identity.Models
+{
+    public sealed class RoleDto
+    {
+        public required string ID { get; init; }
+        public string? Name { get; init; }
+        public IReadOnlyList<string> Claims { get; init; } = [];
+    }
+}

+ 13 - 0
Application/Abstractions/Identity/Models/UserRolesDto.cs

@@ -0,0 +1,13 @@
+namespace Application.Abstractions.Identity.Models
+{
+    public class UserRolesDto
+    {
+        public List<Checkbox> Roles { get; set; } = [];
+
+        public class Checkbox
+        {
+            public string? DisplayValue { get; set; }
+            public bool IsSelected { get; set; }
+        }
+    }
+}

+ 6 - 0
Application/Features/Director/CreateRole/Command.cs

@@ -0,0 +1,6 @@
+using MediatR;
+
+namespace Application.Features.Director.CreateRole
+{
+    public sealed record CreateRoleCommand(string? RoleName) : IRequest;
+}

+ 13 - 0
Application/Features/Director/CreateRole/Handler.cs

@@ -0,0 +1,13 @@
+using Application.Abstractions.Identity;
+using MediatR;
+
+namespace Application.Features.Director.CreateRole
+{
+    public sealed class Handler(IIdentityRoleWriter roleWriter) : IRequestHandler<CreateRoleCommand>
+    {
+        public async Task Handle(CreateRoleCommand request, CancellationToken ct)
+        {
+            await roleWriter.CreateRoleAsync(request.RoleName ?? string.Empty, ct);
+        }
+    }
+}

+ 6 - 0
Application/Features/Director/DeleteRole/Command.cs

@@ -0,0 +1,6 @@
+using MediatR;
+
+namespace Application.Features.Director.DeleteRole
+{
+    public sealed record DeleteRoleCommand(string? RoleID) : IRequest;
+}

+ 13 - 0
Application/Features/Director/DeleteRole/Handler.cs

@@ -0,0 +1,13 @@
+using Application.Abstractions.Identity;
+using MediatR;
+
+namespace Application.Features.Director.DeleteRole
+{
+    public sealed class Handler(IIdentityRoleWriter roleWriter) : IRequestHandler<DeleteRoleCommand>
+    {
+        public async Task Handle(DeleteRoleCommand request, CancellationToken ct)
+        {
+            await roleWriter.DeleteRoleAsync(request.RoleID ?? string.Empty, ct);
+        }
+    }
+}

+ 66 - 0
Application/Features/Director/GetRolePermissions/Handler.cs

@@ -0,0 +1,66 @@
+using Application.Abstractions.Identity;
+using SharedKernel.Constants;
+using MediatR;
+using System.Data;
+
+namespace Application.Features.Director.GetRolePermissions
+{
+    public sealed class Handler(IIdentityRoleReader roleReader) : IRequestHandler<GetRoleClaimsQuery, Response>
+    {
+        public async Task<Response> Handle(GetRoleClaimsQuery request, CancellationToken ct)
+        {
+            var roleAndClaims = await roleReader.GetRoleAsync(request.RoleID, ct);
+            if (roleAndClaims is null)
+            {
+                throw new DataException($"Role with ID '{request.RoleID}' not found.");
+            }
+
+            string[] DefaultActions = { "Create", "View", "Edit", "Delete" };
+            var groups = new List<Response.PermissionGroup>();
+            var menus = Menus.GetMenus();
+
+            void Traverse(Menu menu, string? parentName = null)
+            {
+                var fullGroupName = string.IsNullOrWhiteSpace(parentName) ? menu.Name : $"{parentName} - {menu.Name}";
+                var fullName = string.IsNullOrWhiteSpace(parentName) ? $"{menu.Name}.{menu.Id}" : $"{parentName} - {menu.Name}.{menu.Id}";
+
+                // 권한 그룹 생성
+                if (!string.IsNullOrWhiteSpace(menu.Path) || (menu.Children != null && menu.Children.Count > 0))
+                {
+                    groups.Add(new Response.PermissionGroup
+                    {
+                        GroupName = fullGroupName,
+                        Permissions = [..DefaultActions.Select(action =>
+                        {
+                            var permission = $"Permissions.{fullName}.{action}";
+
+                            return new Response.PermissionGroup.Checkbox {
+                                DisplayValue = permission,
+                                IsSelected = roleAndClaims.Claims.Contains(permission)
+                            };
+                        })]
+                    });
+                }
+
+                if (menu.Children != null)
+                {
+                    foreach (var child in menu.Children)
+                    {
+                        Traverse(child, menu.Name);
+                    }
+                }
+            }
+
+            foreach (var menu in menus)
+            {
+                Traverse(menu);
+            }
+
+            return new Response {
+                RoleID = roleAndClaims.ID,
+                RoleName = roleAndClaims.Name,
+                RoleClaims = groups
+            };
+        }
+    }
+}

+ 6 - 0
Application/Features/Director/GetRolePermissions/Query.cs

@@ -0,0 +1,6 @@
+using MediatR;
+
+namespace Application.Features.Director.GetRolePermissions
+{
+    public sealed record GetRoleClaimsQuery(string RoleID) : IRequest<Response>;
+}

+ 29 - 0
Application/Features/Director/GetRolePermissions/Response.cs

@@ -0,0 +1,29 @@
+namespace Application.Features.Director.GetRolePermissions
+{
+    public sealed class Response
+    {
+        public required string RoleID { get; set; }
+        public string? RoleName { get; set; }
+
+        public class PermissionGroup
+        {
+            public required string GroupName { get; set; }
+
+            public class Checkbox
+            {
+                public string? DisplayValue { get; set; }
+                public bool IsSelected { get; set; }
+            }
+
+            public List<Checkbox> Permissions { get; set; } = [];
+
+            // 전체 선택 여부 (모든 권한이 선택됨)
+            public bool IsAllSelected => Permissions.All(c => c.IsSelected);
+
+            // 일부만 선택됨 (일부 권한만 선택됨)
+            public bool IsPartialSelected => Permissions.Any(c => c.IsSelected) && !IsAllSelected;
+        }
+
+        public List<PermissionGroup> RoleClaims { get; set; } = [];
+    }
+}

+ 20 - 0
Application/Features/Director/GetRoles/Handler.cs

@@ -0,0 +1,20 @@
+using Application.Abstractions.Identity;
+using MediatR;
+
+namespace Application.Features.Director.GetRoles
+{
+    public sealed class Handler(IIdentityRoleReader roleReader) : IRequestHandler<GetAllRolesQuery, List<Response>>
+    {
+        public async Task<List<Response>> Handle(GetAllRolesQuery request, CancellationToken ct)
+        {
+            var roles = await roleReader.GetRolesAsync(ct);
+
+            return [..roles.Select(role => new Response
+            {
+                ID = role.ID,
+                Name = role.Name,
+                ClaimsCount = role.Claims.Count
+            })];
+        }
+    }
+}

+ 6 - 0
Application/Features/Director/GetRoles/Query.cs

@@ -0,0 +1,6 @@
+using MediatR;
+
+namespace Application.Features.Director.GetRoles
+{
+    public sealed record GetAllRolesQuery() : IRequest<List<Response>>;
+}

+ 9 - 0
Application/Features/Director/GetRoles/Response.cs

@@ -0,0 +1,9 @@
+namespace Application.Features.Director.GetRoles
+{
+    public class Response
+    {
+        public required string ID { get; set; }
+        public string? Name { get; set; }
+        public int ClaimsCount { get; set; } = 0;
+    }
+}

+ 14 - 0
Application/Features/Director/GetUser/Handler.cs

@@ -0,0 +1,14 @@
+using Application.Abstractions.Identity;
+using Application.Abstractions.Identity.Models;
+using MediatR;
+
+namespace Application.Features.Director.GetUser
+{
+    public sealed class Handler(IIdentityUserReader userReader) : IRequestHandler<GetUserQuery, AspNetUserDto?>
+    {
+        public async Task<AspNetUserDto?> Handle(GetUserQuery request, CancellationToken ct)
+        {
+            return await userReader.GetUserAsync(request.ID, ct);
+        }
+    }
+}

+ 7 - 0
Application/Features/Director/GetUser/Query.cs

@@ -0,0 +1,7 @@
+using Application.Abstractions.Identity.Models;
+using MediatR;
+
+namespace Application.Features.Director.GetUser
+{
+    public sealed record GetUserQuery(string ID) : IRequest<AspNetUserDto?>;
+}

+ 46 - 0
Application/Features/Director/GetUser/Response.cs

@@ -0,0 +1,46 @@
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Application.Features.Director.GetUser
+{
+    public class Response
+    {
+        [DisplayName("PK")]
+        [Required(ErrorMessage = "ID는 필수 항목입니다.")]
+        public required string ID { get; set; }
+
+        [DisplayName("이름")]
+        [Required(ErrorMessage = "{0}을 입력해주세요.")]
+        public string? Name { get; set; }
+
+        [DisplayName("이메일")]
+        [Required(ErrorMessage = "{0}를 입력하세요.")]
+        [EmailAddress(ErrorMessage = "올바른 이메일 주소를 입력하세요.")]
+        public string? Email { get; set; }
+
+        [DisplayName("연락처")]
+        [Phone(ErrorMessage = "올바른 연락처를 입력하세요.")]
+        public string? Phone { get; set; }
+
+        [DisplayName("새 비밀번호")]
+        [DataType(DataType.Password)]
+        [StringLength(100, ErrorMessage = "{0}은 {1}자 이하로 입력하세요.", MinimumLength = 6)]
+        public string? NewPassword { get; set; }
+
+        [DisplayName("새 비밀번호 재입력")]
+        [DataType(DataType.Password)]
+        [Compare("NewPassword", ErrorMessage = "두 비밀번호가 서로 일치하지 않습니다.")]
+        public string? ConfirmPassword { get; set; }
+
+        [DisplayName("비활성화")]
+        public bool IsDeleted { get; set; } = false;
+
+        [DisplayName("이메일 인증 여부")]
+        public bool EmailConfirmed { get; set; } = false;
+
+        [DisplayName("로그인 차단")]
+        public bool LockoutEnd { get; set; } = false;
+
+        public IEnumerable<string> Roles { get; set; } = [];
+    }
+}

+ 30 - 0
Application/Features/Director/GetUserRoles/Handler.cs

@@ -0,0 +1,30 @@
+using Application.Abstractions.Identity;
+using MediatR;
+
+namespace Application.Features.Director.GetUserRoles
+{
+    public sealed class Handler(IIdentityRoleReader roleReader, IIdentityUserReader userReader) : IRequestHandler<GetUserRolesQuery, Response?>
+    {
+        public async Task<Response> Handle(GetUserRolesQuery request, CancellationToken ct)
+        {
+            var user = await userReader.GetUserAsync(request.UserID, ct);
+            if (user == null)
+            {
+                throw new Exception("회원 정보를 찾을 수 없습니다.");
+            }
+
+            var roles = await roleReader.GetRolesAsync(ct);
+            var userRoleSet = new HashSet<string>(user.Roles, StringComparer.OrdinalIgnoreCase);
+
+            return new Response
+            {
+                User = user,
+                Roles = [..roles.Select(role => new Response.Checkbox
+                {
+                    DisplayValue = role.Name,
+                    IsSelected = role.Name != null && userRoleSet.Contains(role.Name)
+                })]
+            };
+        }
+    }
+}

+ 6 - 0
Application/Features/Director/GetUserRoles/Query.cs

@@ -0,0 +1,6 @@
+using MediatR;
+
+namespace Application.Features.Director.GetUserRoles
+{
+    public sealed record GetUserRolesQuery(string UserID) : IRequest<Response>;
+}

+ 20 - 0
Application/Features/Director/GetUserRoles/Response.cs

@@ -0,0 +1,20 @@
+using Application.Abstractions.Identity.Models;
+using System.ComponentModel;
+
+namespace Application.Features.Director.GetUserRoles
+{
+    public class Response
+    {
+        public required AspNetUserDto User { get; set; }
+
+        [DisplayName("역할 목록")]
+        public List<Checkbox>? Roles { get; set; } = [];
+
+        public class Checkbox
+        {
+            [DisplayName("역할 이름")]
+            public string? DisplayValue { get; set; }
+            public bool IsSelected { get; set; }
+        }
+    }
+}

+ 25 - 0
Application/Features/Director/GetUsers/Handler.cs

@@ -0,0 +1,25 @@
+using Application.Abstractions.Identity;
+using MediatR;
+
+namespace Application.Features.Director.GetUsers
+{
+    public sealed class Handler(IIdentityUserReader userReader) : IRequestHandler<GetAllUserQuery, List<Response>>
+    {
+        public async Task<List<Response>> Handle(GetAllUserQuery request, CancellationToken ct)
+        {
+            var users = await userReader.GetAllUserAsync(ct);
+
+            return [..users.Select(user => new Response
+            {
+                ID = user.ID,
+                Name = user.FullName,
+                Email = user.Email,
+                Phone = user.PhoneNumber,
+                IsDeleted = user.IsDeleted,
+                EmailConfirmed = user.EmailConfirmed,
+                LockoutEnd = user.LockoutEnabled,
+                Roles = user.Roles
+            })];
+        }
+    }
+}

+ 6 - 0
Application/Features/Director/GetUsers/Query.cs

@@ -0,0 +1,6 @@
+using MediatR;
+
+namespace Application.Features.Director.GetUsers
+{
+    public sealed record GetAllUserQuery() : IRequest<List<Response>>;
+}

+ 15 - 0
Application/Features/Director/GetUsers/Response.cs

@@ -0,0 +1,15 @@
+namespace Application.Features.Director.GetUsers
+{
+    public class Response
+    {
+        public required string ID { get; set; }
+        public string? Name { get; set; }
+        public string? Email { get; set; }
+        public string? Phone { get; set; }
+        public bool IsDeleted { get; set; }
+        public bool EmailConfirmed { get; set; }
+        public bool LockoutEnd { get; set; }
+
+        public IEnumerable<string> Roles { get; set; } = [];
+    }
+}

+ 10 - 0
Application/Features/Director/UpdateRolePermissions/Command.cs

@@ -0,0 +1,10 @@
+using Application.Abstractions.Identity.Models;
+using MediatR;
+
+namespace Application.Features.Director.UpdateRolePermissions
+{
+    public sealed record UpdateRolePermissionsCommand(
+        string RoleID,
+        PermissionDto Permissions
+    ) : IRequest;
+}

+ 13 - 0
Application/Features/Director/UpdateRolePermissions/Handler.cs

@@ -0,0 +1,13 @@
+using Application.Abstractions.Identity;
+using MediatR;
+
+namespace Application.Features.Director.UpdateRolePermissions
+{
+    public sealed class Handler(IIdentityRoleWriter roleWriter) : IRequestHandler<UpdateRolePermissionsCommand>
+    {
+        public async Task Handle(UpdateRolePermissionsCommand request, CancellationToken ct)
+        {
+            await roleWriter.UpdateRoleAsync(request.RoleID, request.Permissions, ct);
+        }
+    }
+}

+ 16 - 0
Application/Features/Director/UpdateUser/Command.cs

@@ -0,0 +1,16 @@
+using MediatR;
+
+namespace Application.Features.Director.UpdateUser
+{
+    public sealed record UpdateUserCommand(
+        string ID,
+        string? FullName,
+        string? Email,
+        string? PhoneNumber,
+        string? NewPassword,
+        string? ConfirmPassword,
+        bool IsDeleted,
+        bool EmailConfirmed,
+        bool LockoutEnd
+    ) : IRequest;
+}

+ 25 - 0
Application/Features/Director/UpdateUser/Handler.cs

@@ -0,0 +1,25 @@
+using Application.Abstractions.Identity;
+using Application.Abstractions.Identity.Models;
+using MediatR;
+
+namespace Application.Features.Director.UpdateUser
+{
+    public sealed class Handler(IIdentityUserWriter userWriter) : IRequestHandler<UpdateUserCommand>
+    {
+        public async Task Handle(UpdateUserCommand request, CancellationToken ct)
+        {
+            await userWriter.UpdateUserAsync(new ApplicationUserDto
+            {
+                ID = request.ID,
+                Name = request.FullName,
+                Email = request.Email,
+                Phone = request.PhoneNumber,
+                NewPassword = request.NewPassword,
+                ConfirmPassword = request.ConfirmPassword,
+                IsDeleted = request.IsDeleted,
+                EmailConfirmed = request.EmailConfirmed,
+                LockoutEnd = request.LockoutEnd,
+            }, ct);
+        }
+    }
+}

+ 11 - 0
Application/Features/Director/UpdateUserRoles/Command.cs

@@ -0,0 +1,11 @@
+using Application.Abstractions.Identity.Models;
+using MediatR;
+
+namespace Application.Features.Director.UpdateUserRoles
+{
+    public sealed record UpdateUserRolesCommand
+    (
+        string UserID,
+        UserRolesDto? Roles
+    ) : IRequest;
+}

+ 13 - 0
Application/Features/Director/UpdateUserRoles/Handler.cs

@@ -0,0 +1,13 @@
+using Application.Abstractions.Identity;
+using MediatR;
+
+namespace Application.Features.Director.UpdateUserRoles
+{
+    public sealed class Handler(IIdentityUserWriter userWriter) : IRequestHandler<UpdateUserRolesCommand>
+    {
+        public async Task Handle(UpdateUserRolesCommand request, CancellationToken ct)
+        {
+            await userWriter.UpdateUserRolesAsync(request.UserID, request.Roles, ct);
+        }
+    }
+}

+ 6 - 0
Infrastructure/DependencyInjection.cs

@@ -1,7 +1,9 @@
 using Application.Abstractions.Data;
+using Application.Abstractions.Identity;
 using Application.Abstractions.Messaging.Email;
 using Infrastructure.Messaging.Email;
 using Infrastructure.Persistence;
+using Infrastructure.Persistence.Identity;
 using Infrastructure.Storage;
 using Microsoft.AspNetCore.DataProtection;
 using Microsoft.AspNetCore.Identity;
@@ -65,6 +67,10 @@ namespace Infrastructure
             services.AddTransient<IEmailSender, IdentityEmailSender>();
             services.AddScoped<IFileStorage, LocalFileStorage>();
             services.AddScoped<IEditorImageService, EditorImageService>();
+            services.AddScoped<IIdentityUserReader, IdentityUserReader>();
+            services.AddScoped<IIdentityUserWriter, IdentityUserWriter>();
+            services.AddScoped<IIdentityRoleReader, IdentityRoleReader>();
+            services.AddScoped<IIdentityRoleWriter, IdentityRoleWriter>();
 
             return services;
         }

+ 22 - 0
Infrastructure/Persistence/Identity/ApplicationUser.cs

@@ -41,9 +41,31 @@ namespace Infrastructure.Persistence.Identity
             FullName = string.IsNullOrWhiteSpace(fullName) ? null : fullName.Trim();
         }
 
+        public void SetEmail(string? email)
+        {
+            Email = string.IsNullOrWhiteSpace(email) ? null : email.Trim();
+            NormalizedEmail = Email?.ToUpperInvariant();
+        }
+
+        public void SetPhoneNumber(string? phoneNumber)
+        {
+            PhoneNumber = string.IsNullOrWhiteSpace(phoneNumber) ? null : phoneNumber.Trim();
+        }
+
         public void SetDeleted(bool isDeleted)
         {
             IsDeleted = isDeleted;
         }
+
+        public void SetEmailConfirmed(bool emailConfirmed)
+        {
+            EmailConfirmed = emailConfirmed;
+        }
+
+        public void SetLockoutEnd(bool lockoutEnd)
+        {
+            LockoutEnabled = lockoutEnd;
+            LockoutEnd = lockoutEnd ? DateTimeOffset.MaxValue : null;
+        }
     }
 }

+ 52 - 0
Infrastructure/Persistence/Identity/IdentityRoleReader.cs

@@ -0,0 +1,52 @@
+using Application.Abstractions.Identity;
+using Application.Abstractions.Identity.Models;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.EntityFrameworkCore;
+
+namespace Infrastructure.Persistence.Identity;
+
+public sealed class IdentityRoleReader(RoleManager<IdentityRole> roleManager) : IIdentityRoleReader
+{
+    public async Task<List<RoleDto>> GetRolesAsync(CancellationToken ct)
+    {
+        var roles = await roleManager.Roles.AsNoTracking().ToListAsync(ct);
+        var list = new List<RoleDto>(roles.Count);
+
+        foreach (var role in roles)
+        {
+            var claims = await roleManager.GetClaimsAsync(role);
+
+            list.Add(new RoleDto
+            {
+                ID = role.Id,
+                Name = role.Name,
+                Claims = [..claims.Select(c => c.Value)]
+            });
+        }
+
+        return list;
+    }
+
+    public async Task<RoleDto> GetRoleAsync(string roleID, CancellationToken ct)
+    {
+        if (string.IsNullOrWhiteSpace(roleID))
+        {
+            throw new InvalidOperationException("RoleID는 필수입니다.");
+        }
+
+        var role = await roleManager.FindByIdAsync(roleID);
+        if (role is null)
+        {
+            throw new InvalidOperationException($"{roleID} 권한은 존재하지 않습니다.");
+        }
+
+        var claims = await roleManager.GetClaimsAsync(role);
+        var values = claims.Select(c => c.Value).ToList();
+
+        return new RoleDto{
+            ID = role.Id,
+            Name = role.Name ?? string.Empty,
+            Claims = values
+        };
+    }
+}

+ 96 - 0
Infrastructure/Persistence/Identity/IdentityRoleWriter.cs

@@ -0,0 +1,96 @@
+using Application.Abstractions.Identity;
+using Application.Abstractions.Identity.Models;
+using Microsoft.AspNetCore.Identity;
+using System.Security.Claims;
+
+namespace Infrastructure.Persistence.Identity
+{
+    public sealed class IdentityRoleWriter(RoleManager<IdentityRole> roleManager) : IIdentityRoleWriter
+    {
+        public async Task CreateRoleAsync(string roleName, CancellationToken ct)
+        {
+            var name = roleName?.Trim();
+
+            if (string.IsNullOrWhiteSpace(name))
+            {
+                throw new InvalidOperationException("역할 이름은 필수입니다.");
+            }
+
+            if (name.Length > 256)
+            {
+                throw new InvalidOperationException("역할 이름은 256자를 초과할 수 없습니다.");
+            }
+
+            if (await roleManager.RoleExistsAsync(name))
+            {
+                throw new InvalidOperationException($"{name} 은 이미 존재합니다.");
+            }
+
+            var result = await roleManager.CreateAsync(new IdentityRole(name));
+
+            if (!result.Succeeded)
+            {
+                throw new InvalidOperationException(string.Join(Environment.NewLine, result.Errors.Select(e => e.Description)));
+            }
+        }
+
+        public async Task DeleteRoleAsync(string roleID, CancellationToken ct)
+        {
+            if (string.IsNullOrWhiteSpace(roleID))
+            {
+                throw new InvalidOperationException("Role ID는 필수입니다.");
+            }
+
+            var role = await roleManager.FindByIdAsync(roleID);
+            if (role is null)
+            {
+                throw new InvalidOperationException("역할을 찾을 수 없습니다.");
+            }
+
+            var result = await roleManager.DeleteAsync(role);
+            if (!result.Succeeded)
+            {
+                throw new InvalidOperationException(string.Join(Environment.NewLine, result.Errors.Select(e => e.Description)));
+            }
+        }
+
+        public async Task UpdateRoleAsync(string roleID, PermissionDto permission, CancellationToken ct)
+        {
+            if (string.IsNullOrWhiteSpace(roleID))
+            {
+                throw new InvalidOperationException("Role ID는 필수입니다.");
+            }
+
+            var role = await roleManager.FindByIdAsync(roleID);
+            if (role == null)
+            {
+                throw new InvalidOperationException("역할을 찾을 수 없습니다.");
+            }
+
+            // 기존 권한 제거
+            var existingRoleClaims = await roleManager.GetClaimsAsync(role);
+            foreach (var claim in existingRoleClaims.Where(c => c.Type == "Permission"))
+            {
+                var removed = await roleManager.RemoveClaimAsync(role, claim);
+                if (!removed.Succeeded)
+                {
+                    throw new InvalidOperationException(string.Join(Environment.NewLine, removed.Errors.Select(e => e.Description)));
+                }
+            }
+
+            // 선택된 권한 추출
+            var selectedClaims = permission.RoleClaims.SelectMany(c => c.Permissions).Where(c => c.IsSelected).Select(c => c.DisplayValue).Where(v => !string.IsNullOrWhiteSpace(v)).ToList();
+            if (selectedClaims is not null)
+            {
+                foreach (var value in selectedClaims)
+                {
+                    var added = await roleManager.AddClaimAsync(role, new Claim("Permission", value!));
+                    if (!added.Succeeded)
+                    {
+                        throw new InvalidOperationException(string.Join(Environment.NewLine, added.Errors.Select(e => e.Description)));
+                    }
+                }
+            }
+        }
+    }
+}

+ 86 - 0
Infrastructure/Persistence/Identity/IdentityUserReader.cs

@@ -0,0 +1,86 @@
+using Application.Abstractions.Identity;
+using Application.Abstractions.Identity.Models;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.EntityFrameworkCore;
+
+namespace Infrastructure.Persistence.Identity
+{
+    public sealed class IdentityUserReader(UserManager<ApplicationUser> userManager, IdentityDbContext db) : IIdentityUserReader
+    {
+        public async Task<AspNetUserDto?> GetUserAsync(string id, CancellationToken ct)
+        {
+            if (string.IsNullOrWhiteSpace(id))
+            {
+                return null;
+            }
+
+            var user = await userManager.FindByIdAsync(id);
+            if (user is null)
+            {
+                return null;
+            }
+
+            var roles = await userManager.GetRolesAsync(user);
+
+            return new AspNetUserDto
+            {
+                ID = user.Id,
+                UserName = user.UserName,
+                NormalizedUserName = user.NormalizedUserName,
+                Email = user.Email,
+                NormalizedEmail = user.NormalizedEmail,
+                EmailConfirmed = user.EmailConfirmed,
+                PhoneNumber = user.PhoneNumber,
+                PhoneNumberConfirmed = user.PhoneNumberConfirmed,
+                TwoFactorEnabled = user.TwoFactorEnabled,
+                LockoutEnd = user.LockoutEnd,
+                LockoutEnabled = user.LockoutEnabled,
+                AccessFailedCount = user.AccessFailedCount,
+                FullName = user.FullName,
+                IsDeleted = user.IsDeleted,
+                Roles = [..roles]
+            };
+        }
+
+        public async Task<List<AspNetUserDto>> GetAllUserAsync(CancellationToken ct)
+        {
+            var rows = await (
+                from u in db.Users.AsNoTracking()
+                join ur in db.UserRoles.AsNoTracking() on u.Id equals ur.UserId into userRoles
+                from ur in userRoles.DefaultIfEmpty()
+                join r in db.Roles.AsNoTracking() on ur.RoleId equals r.Id into roles
+                from r in roles.DefaultIfEmpty()
+                select new
+                {
+                    User = u,
+                    RoleName = r != null ? r.Name : null
+                }
+            ).ToListAsync();
+
+            return [..rows.GroupBy(x => x.User.Id).Select(g =>
+            {
+                var u = g.First().User;
+                var roles = g.Select(x => x.RoleName).Where(rn => rn != null).ToList();
+
+                return new AspNetUserDto
+                {
+                    ID = u.Id,
+                    UserName = u.UserName,
+                    NormalizedUserName = u.NormalizedUserName,
+                    Email = u.Email,
+                    NormalizedEmail = u.NormalizedEmail,
+                    EmailConfirmed = u.EmailConfirmed,
+                    PhoneNumber = u.PhoneNumber,
+                    PhoneNumberConfirmed = u.PhoneNumberConfirmed,
+                    TwoFactorEnabled = u.TwoFactorEnabled,
+                    LockoutEnd = u.LockoutEnd,
+                    LockoutEnabled = u.LockoutEnabled,
+                    AccessFailedCount = u.AccessFailedCount,
+                    FullName = u.FullName,
+                    IsDeleted = u.IsDeleted,
+                    Roles = roles
+                };
+            })];
+        }
+    }
+}

+ 109 - 0
Infrastructure/Persistence/Identity/IdentityUserWriter.cs

@@ -0,0 +1,109 @@
+using Application.Abstractions.Identity;
+using Application.Abstractions.Identity.Models;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.EntityFrameworkCore;
+
+namespace Infrastructure.Persistence.Identity
+{
+    public sealed class IdentityUserWriter(UserManager<ApplicationUser> userManager) : IIdentityUserWriter
+    {
+        public async Task UpdateUserAsync(ApplicationUserDto a, CancellationToken ct)
+        {
+            if (string.IsNullOrWhiteSpace(a.ID))
+            {
+                throw new InvalidOperationException("ID는 필수입니다.");
+            }
+
+            var user = await userManager.FindByIdAsync(a.ID);
+            if (user is null)
+            {
+                throw new InvalidOperationException("사용자 정보를 찾을 수 없습니다.");
+            }
+
+            // 이메일 중복 확인(본인 제외)
+            if (!string.IsNullOrWhiteSpace(a.Email))
+            {
+                var exists = await userManager.Users.AsNoTracking().AnyAsync(u => u.Email == a.Email && u.Id != a.ID, ct);
+                if (exists)
+                {
+                    throw new InvalidOperationException("이미 존재하는 이메일 주소입니다.");
+                }
+            }
+
+            user.SetFullName(a.Name);
+            user.SetEmail(a.Email);
+            user.SetPhoneNumber(a.Phone);
+            user.SetDeleted(a.IsDeleted);
+            user.SetEmailConfirmed(a.EmailConfirmed);
+            user.SetLockoutEnd(a.LockoutEnd);
+
+            // 비밀번호 변경(입력되었을 때만)
+            if (!string.IsNullOrWhiteSpace(a.NewPassword))
+            {
+                if (a.NewPassword != a.ConfirmPassword)
+                {
+                    throw new InvalidOperationException("두 비밀번호가 서로 일치하지 않습니다.");
+                }
+
+                var token = await userManager.GeneratePasswordResetTokenAsync(user);
+                var reset = await userManager.ResetPasswordAsync(user, token, a.NewPassword);
+
+                if (!reset.Succeeded)
+                {
+                    throw new InvalidOperationException(string.Join(Environment.NewLine, reset.Errors.Select(x => x.Description)));
+                }
+            }
+
+            var updated = await userManager.UpdateAsync(user);
+            if (!updated.Succeeded)
+            {
+                throw new InvalidOperationException(string.Join(Environment.NewLine, updated.Errors.Select(x => x.Description)));
+            }
+        }
+
+        public async Task UpdateUserRolesAsync(string userID, UserRolesDto? b, CancellationToken ct)
+        {
+            if (string.IsNullOrWhiteSpace(userID))
+            {
+                throw new InvalidOperationException("ID는 필수입니다.");
+            }
+
+            var user = await userManager.FindByIdAsync(userID);
+            if (user is null)
+            {
+                throw new InvalidOperationException("사용자 정보를 찾을 수 없습니다.");
+            }
+
+            var userRoles = await userManager.GetRolesAsync(user);
+
+            foreach (var role in b?.Roles ?? Enumerable.Empty<UserRolesDto.Checkbox>())
+            {
+                var roleName = role.DisplayValue?.Trim();
+                if (string.IsNullOrWhiteSpace(roleName))
+                {
+                    continue;
+                }
+
+                // 현재 사용자의 역할에 포함되어 있으나 선택되지 않은 경우 제거
+                if (userRoles.Contains(roleName, StringComparer.OrdinalIgnoreCase) && !role.IsSelected)
+                {
+                    var removed = await userManager.RemoveFromRoleAsync(user, roleName);
+                    if (!removed.Succeeded)
+                    {
+                        throw new InvalidOperationException(string.Join(Environment.NewLine, removed.Errors.Select(e => e.Description)));
+                    }
+                }
+
+                // 현재 사용자의 역할에 포함되지 않았으나 선택된 경우 추가
+                if (!userRoles.Contains(roleName, StringComparer.OrdinalIgnoreCase) && role.IsSelected)
+                {
+                    var added = await userManager.AddToRoleAsync(user, roleName);
+                    if (!added.Succeeded)
+                    {
+                        throw new InvalidOperationException(string.Join(Environment.NewLine, added.Errors.Select(e => e.Description)));
+                    }
+                }
+            }
+        }
+    }
+}

+ 1 - 1
Admin/Constants/Menus.cs → SharedKernel/Constants/Menus.cs

@@ -1,7 +1,7 @@
 using Microsoft.AspNetCore.Authorization;
 using System.Security.Claims;
 
-namespace Admin.Constants
+namespace SharedKernel.Constants
 {
     public class Menu
     {

+ 1 - 0
SharedKernel/SharedKernel.csproj

@@ -7,6 +7,7 @@
   </PropertyGroup>
 
   <ItemGroup>
+    <PackageReference Include="Microsoft.AspNetCore.Authorization" Version="10.0.2" />
     <PackageReference Include="Microsoft.AspNetCore.Http" Version="2.3.9" />
   </ItemGroup>