KIM-JINO5 2 месяцев назад
Сommit
b47af4b08a
100 измененных файлов с 7515 добавлено и 0 удалено
  1. 20 0
      .gitignore
  2. 49 0
      Admin/Admin.csproj
  3. 8 0
      Admin/Admin.slnx
  4. 10 0
      Admin/Areas/Identity/Pages/Account/AccessDenied.cshtml
  5. 23 0
      Admin/Areas/Identity/Pages/Account/AccessDenied.cshtml.cs
  6. 17 0
      Admin/Areas/Identity/Pages/Account/ConfirmEmail.cshtml
  7. 52 0
      Admin/Areas/Identity/Pages/Account/ConfirmEmail.cshtml.cs
  8. 17 0
      Admin/Areas/Identity/Pages/Account/ConfirmEmailChange.cshtml
  9. 70 0
      Admin/Areas/Identity/Pages/Account/ConfirmEmailChange.cshtml.cs
  10. 33 0
      Admin/Areas/Identity/Pages/Account/ExternalLogin.cshtml
  11. 224 0
      Admin/Areas/Identity/Pages/Account/ExternalLogin.cshtml.cs
  12. 42 0
      Admin/Areas/Identity/Pages/Account/ForgotPassword.cshtml
  13. 85 0
      Admin/Areas/Identity/Pages/Account/ForgotPassword.cshtml.cs
  14. 19 0
      Admin/Areas/Identity/Pages/Account/ForgotPasswordConfirmation.cshtml
  15. 25 0
      Admin/Areas/Identity/Pages/Account/ForgotPasswordConfirmation.cshtml.cs
  16. 10 0
      Admin/Areas/Identity/Pages/Account/Lockout.cshtml
  17. 25 0
      Admin/Areas/Identity/Pages/Account/Lockout.cshtml.cs
  18. 95 0
      Admin/Areas/Identity/Pages/Account/Login.cshtml
  19. 164 0
      Admin/Areas/Identity/Pages/Account/Login.cshtml.cs
  20. 39 0
      Admin/Areas/Identity/Pages/Account/LoginWith2fa.cshtml
  21. 132 0
      Admin/Areas/Identity/Pages/Account/LoginWith2fa.cshtml.cs
  22. 29 0
      Admin/Areas/Identity/Pages/Account/LoginWithRecoveryCode.cshtml
  23. 113 0
      Admin/Areas/Identity/Pages/Account/LoginWithRecoveryCode.cshtml.cs
  24. 21 0
      Admin/Areas/Identity/Pages/Account/Logout.cshtml
  25. 43 0
      Admin/Areas/Identity/Pages/Account/Logout.cshtml.cs
  26. 36 0
      Admin/Areas/Identity/Pages/Account/Manage/ChangePassword.cshtml
  27. 128 0
      Admin/Areas/Identity/Pages/Account/Manage/ChangePassword.cshtml.cs
  28. 33 0
      Admin/Areas/Identity/Pages/Account/Manage/DeletePersonalData.cshtml
  29. 104 0
      Admin/Areas/Identity/Pages/Account/Manage/DeletePersonalData.cshtml.cs
  30. 25 0
      Admin/Areas/Identity/Pages/Account/Manage/Disable2fa.cshtml
  31. 70 0
      Admin/Areas/Identity/Pages/Account/Manage/Disable2fa.cshtml.cs
  32. 12 0
      Admin/Areas/Identity/Pages/Account/Manage/DownloadPersonalData.cshtml
  33. 68 0
      Admin/Areas/Identity/Pages/Account/Manage/DownloadPersonalData.cshtml.cs
  34. 44 0
      Admin/Areas/Identity/Pages/Account/Manage/Email.cshtml
  35. 172 0
      Admin/Areas/Identity/Pages/Account/Manage/Email.cshtml.cs
  36. 53 0
      Admin/Areas/Identity/Pages/Account/Manage/EnableAuthenticator.cshtml
  37. 189 0
      Admin/Areas/Identity/Pages/Account/Manage/EnableAuthenticator.cshtml.cs
  38. 53 0
      Admin/Areas/Identity/Pages/Account/Manage/ExternalLogins.cshtml
  39. 142 0
      Admin/Areas/Identity/Pages/Account/Manage/ExternalLogins.cshtml.cs
  40. 27 0
      Admin/Areas/Identity/Pages/Account/Manage/GenerateRecoveryCodes.cshtml
  41. 83 0
      Admin/Areas/Identity/Pages/Account/Manage/GenerateRecoveryCodes.cshtml.cs
  42. 35 0
      Admin/Areas/Identity/Pages/Account/Manage/Index.cshtml
  43. 135 0
      Admin/Areas/Identity/Pages/Account/Manage/Index.cshtml.cs
  44. 123 0
      Admin/Areas/Identity/Pages/Account/Manage/ManageNavPages.cs
  45. 55 0
      Admin/Areas/Identity/Pages/Account/Manage/PersonalData.cshtml
  46. 37 0
      Admin/Areas/Identity/Pages/Account/Manage/PersonalData.cshtml.cs
  47. 24 0
      Admin/Areas/Identity/Pages/Account/Manage/ResetAuthenticator.cshtml
  48. 68 0
      Admin/Areas/Identity/Pages/Account/Manage/ResetAuthenticator.cshtml.cs
  49. 35 0
      Admin/Areas/Identity/Pages/Account/Manage/SetPassword.cshtml
  50. 115 0
      Admin/Areas/Identity/Pages/Account/Manage/SetPassword.cshtml.cs
  51. 25 0
      Admin/Areas/Identity/Pages/Account/Manage/ShowRecoveryCodes.cshtml
  52. 46 0
      Admin/Areas/Identity/Pages/Account/Manage/ShowRecoveryCodes.cshtml.cs
  53. 71 0
      Admin/Areas/Identity/Pages/Account/Manage/TwoFactorAuthentication.cshtml
  54. 90 0
      Admin/Areas/Identity/Pages/Account/Manage/TwoFactorAuthentication.cshtml.cs
  55. 27 0
      Admin/Areas/Identity/Pages/Account/Manage/_Layout.cshtml
  56. 16 0
      Admin/Areas/Identity/Pages/Account/Manage/_ManageNav.cshtml
  57. 10 0
      Admin/Areas/Identity/Pages/Account/Manage/_StatusMessage.cshtml
  58. 1 0
      Admin/Areas/Identity/Pages/Account/Manage/_ViewImports.cshtml
  59. 82 0
      Admin/Areas/Identity/Pages/Account/Register.cshtml
  60. 181 0
      Admin/Areas/Identity/Pages/Account/Register.cshtml.cs
  61. 31 0
      Admin/Areas/Identity/Pages/Account/RegisterConfirmation.cshtml
  62. 80 0
      Admin/Areas/Identity/Pages/Account/RegisterConfirmation.cshtml.cs
  63. 42 0
      Admin/Areas/Identity/Pages/Account/ResendEmailConfirmation.cshtml
  64. 89 0
      Admin/Areas/Identity/Pages/Account/ResendEmailConfirmation.cshtml.cs
  65. 47 0
      Admin/Areas/Identity/Pages/Account/ResetPassword.cshtml
  66. 118 0
      Admin/Areas/Identity/Pages/Account/ResetPassword.cshtml.cs
  67. 19 0
      Admin/Areas/Identity/Pages/Account/ResetPasswordConfirmation.cshtml
  68. 25 0
      Admin/Areas/Identity/Pages/Account/ResetPasswordConfirmation.cshtml.cs
  69. 10 0
      Admin/Areas/Identity/Pages/Account/_StatusMessage.cshtml
  70. 1 0
      Admin/Areas/Identity/Pages/Account/_ViewImports.cshtml
  71. 23 0
      Admin/Areas/Identity/Pages/Error.cshtml
  72. 41 0
      Admin/Areas/Identity/Pages/Error.cshtml.cs
  73. 2 0
      Admin/Areas/Identity/Pages/_ValidationScriptsPartial.cshtml
  74. 4 0
      Admin/Areas/Identity/Pages/_ViewImports.cshtml
  75. 12 0
      Admin/Areas/Identity/Pages/_ViewStart.cshtml
  76. 12 0
      Admin/Extensions/NavActiveExtension.cs
  77. 126 0
      Admin/Extensions/ServiceCollectionExtensions.cs
  78. 62 0
      Admin/Extensions/WebApplicationExtensions.cs
  79. 157 0
      Admin/Middlewares/AdminAccessLogMiddleware.cs
  80. 128 0
      Admin/Middlewares/MenuAuthorizationMiddleware.cs
  81. 153 0
      Admin/Pages/Banner/List/Edit.cshtml
  82. 129 0
      Admin/Pages/Banner/List/Edit.cshtml.cs
  83. 141 0
      Admin/Pages/Banner/List/Index.cshtml
  84. 95 0
      Admin/Pages/Banner/List/Index.cshtml.cs
  85. 117 0
      Admin/Pages/Banner/List/Write.cshtml
  86. 104 0
      Admin/Pages/Banner/List/Write.cshtml.cs
  87. 194 0
      Admin/Pages/Banner/Position.cshtml
  88. 105 0
      Admin/Pages/Banner/Position.cshtml.cs
  89. 8 0
      Admin/Pages/Banner/_NavTabs.cshtml
  90. 122 0
      Admin/Pages/Channel/List/Edit.cshtml
  91. 134 0
      Admin/Pages/Channel/List/Edit.cshtml.cs
  92. 154 0
      Admin/Pages/Channel/List/Index.cshtml
  93. 116 0
      Admin/Pages/Channel/List/Index.cshtml.cs
  94. 267 0
      Admin/Pages/Channel/List/View.cshtml
  95. 79 0
      Admin/Pages/Channel/List/View.cshtml.cs
  96. 198 0
      Admin/Pages/Channel/List/Write.cshtml
  97. 126 0
      Admin/Pages/Channel/List/Write.cshtml.cs
  98. 213 0
      Admin/Pages/Config/Basic/Images.cshtml
  99. 69 0
      Admin/Pages/Config/Basic/Images.cshtml.cs
  100. 182 0
      Admin/Pages/Config/Basic/Index.cshtml

+ 20 - 0
.gitignore

@@ -0,0 +1,20 @@
+.vs/
+**/.vs/
+bin/
+obj/
+*.user
+*.suo
+*.userosscache
+*.sln.docstates
+node_modules/
+**/node_modules/
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+.env
+.env.*
+!.env.example
+Admin/nul
+nul
+Database/*.sql

+ 49 - 0
Admin/Admin.csproj

@@ -0,0 +1,49 @@
+<Project Sdk="Microsoft.NET.Sdk.Web">
+
+	<PropertyGroup>
+		<TargetFramework>net10.0</TargetFramework>
+		<Nullable>enable</Nullable>
+		<ImplicitUsings>enable</ImplicitUsings>
+		<HotReloadAutoRestart>true</HotReloadAutoRestart>
+	</PropertyGroup>
+
+	<ItemGroup>
+	  <None Remove="nul" />
+	</ItemGroup>
+	
+	<ItemGroup>
+		<None Include=".github\copilot-instructions.md" />
+	</ItemGroup>
+
+	<ItemGroup>
+		<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.5" />
+		<PackageReference Include="Microsoft.AspNetCore.Identity.UI" Version="10.0.5" />
+		<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.5">
+			<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+			<PrivateAssets>all</PrivateAssets>
+		</PackageReference>
+		<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.5" />
+		<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.5">
+			<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+			<PrivateAssets>all</PrivateAssets>
+		</PackageReference>
+		<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.5" />
+		<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="10.0.2" />
+		<PackageReference Include="MimeKit" Version="4.15.1" />
+		<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
+		<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
+		<PackageReference Include="System.Management" Version="10.0.5" />
+	</ItemGroup>
+
+	<ItemGroup>
+		<ProjectReference Include="..\Application\Application.csproj" />
+		<ProjectReference Include="..\Infrastructure\Infrastructure.csproj" />
+	</ItemGroup>
+
+	<ItemGroup>
+		<Folder Include="wwwroot\uploads\basic\" />
+		<Folder Include="wwwroot\uploads\banner\" />
+		<Folder Include="wwwroot\uploads\thumb\" />
+	</ItemGroup>
+
+</Project>

+ 8 - 0
Admin/Admin.slnx

@@ -0,0 +1,8 @@
+<Solution>
+  <Project Path="../Application/Application.csproj" Id="cbdcdeca-80d7-44ee-96f4-a980c8ffd984" />
+  <Project Path="../Domain/Domain.csproj" Id="f295e410-1385-4a94-92dc-e4c528e3c63d" />
+  <Project Path="../Infrastructure/Infrastructure.csproj" Id="bf5c91c0-794f-484a-81ec-fe9d0cc6b027" />
+  <Project Path="../SharedKernel/SharedKernel.csproj" Id="8af73947-cdea-4086-884c-bed61a7418b6" />
+  <Project Path="Admin.csproj" />
+  <Project Path="../Web.Api/Web.Api.csproj" />
+</Solution>

+ 10 - 0
Admin/Areas/Identity/Pages/Account/AccessDenied.cshtml

@@ -0,0 +1,10 @@
+@page
+@model AccessDeniedModel
+@{
+    ViewData["Title"] = "Access denied";
+}
+
+<header>
+    <h1 class="text-danger">@ViewData["Title"]</h1>
+    <p class="text-danger">You do not have access to this resource.</p>
+</header>

+ 23 - 0
Admin/Areas/Identity/Pages/Account/AccessDenied.cshtml.cs

@@ -0,0 +1,23 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+#nullable disable
+
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace Admin.Areas.Identity.Pages.Account
+{
+    /// <summary>
+    ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+    ///     directly from your code. This API may change or be removed in future releases.
+    /// </summary>
+    public class AccessDeniedModel : PageModel
+    {
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public void OnGet()
+        {
+        }
+    }
+}

+ 17 - 0
Admin/Areas/Identity/Pages/Account/ConfirmEmail.cshtml

@@ -0,0 +1,17 @@
+@page
+@model ConfirmEmailModel
+@{
+    ViewData["Title"] = "Confirm email";
+}
+
+@*
+<h1>@ViewData["Title"]</h1>
+<partial name="_StatusMessage" model="Model.StatusMessage" />
+*@
+
+<script>
+    setTimeout(() => {
+        alert("이메일이 정상적으로 인증되었습니다.");
+        window.location.replace("/Identity/Account/Login");
+    }, 0);
+</script>

+ 52 - 0
Admin/Areas/Identity/Pages/Account/ConfirmEmail.cshtml.cs

@@ -0,0 +1,52 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+#nullable disable
+
+using System;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.WebUtilities;
+using Infrastructure.Persistence.Identity;
+
+namespace Admin.Areas.Identity.Pages.Account
+{
+    public class ConfirmEmailModel : PageModel
+    {
+        private readonly UserManager<ApplicationUser> _userManager;
+
+        public ConfirmEmailModel(UserManager<ApplicationUser> userManager)
+        {
+            _userManager = userManager;
+        }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        [TempData]
+        public string StatusMessage { get; set; }
+        public async Task<IActionResult> OnGetAsync(string userId, string code)
+        {
+            if (userId == null || code == null)
+            {
+                return RedirectToPage("/Index");
+            }
+
+            var user = await _userManager.FindByIdAsync(userId);
+            if (user == null)
+            {
+                return NotFound($"Unable to load user with ID '{userId}'.");
+            }
+
+            code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code));
+            var result = await _userManager.ConfirmEmailAsync(user, code);
+            StatusMessage = result.Succeeded ? "Thank you for confirming your email." : "Error confirming your email.";
+            return Page();
+        }
+    }
+}

+ 17 - 0
Admin/Areas/Identity/Pages/Account/ConfirmEmailChange.cshtml

@@ -0,0 +1,17 @@
+@page
+@model ConfirmEmailChangeModel
+@{
+    ViewData["Title"] = "Confirm email change";
+}
+
+@*
+<h1>@ViewData["Title"]</h1>
+<partial name="_StatusMessage" model="Model.StatusMessage" />
+*@
+
+<script>
+    setTimeout(() => {
+        alert("이메일이 정상적으로 변경되었습니다.");
+        window.location.replace("/Identity/Account/Login");
+    }, 0);
+</script>

+ 70 - 0
Admin/Areas/Identity/Pages/Account/ConfirmEmailChange.cshtml.cs

@@ -0,0 +1,70 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+#nullable disable
+
+using System;
+using System.Text;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.WebUtilities;
+using Infrastructure.Persistence.Identity;
+
+namespace Admin.Areas.Identity.Pages.Account
+{
+    public class ConfirmEmailChangeModel : PageModel
+    {
+        private readonly UserManager<ApplicationUser> _userManager;
+        private readonly SignInManager<ApplicationUser> _signInManager;
+
+        public ConfirmEmailChangeModel(UserManager<ApplicationUser> userManager, SignInManager<ApplicationUser> signInManager)
+        {
+            _userManager = userManager;
+            _signInManager = signInManager;
+        }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        [TempData]
+        public string StatusMessage { get; set; }
+
+        public async Task<IActionResult> OnGetAsync(string userId, string email, string code)
+        {
+            if (userId == null || email == null || code == null)
+            {
+                return RedirectToPage("/Index");
+            }
+
+            var user = await _userManager.FindByIdAsync(userId);
+            if (user == null)
+            {
+                return NotFound($"Unable to load user with ID '{userId}'.");
+            }
+
+            code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code));
+            var result = await _userManager.ChangeEmailAsync(user, email, code);
+            if (!result.Succeeded)
+            {
+                StatusMessage = "Error changing email.";
+                return Page();
+            }
+
+            // In our UI email and user name are one and the same, so when we update the email
+            // we need to update the user name.
+            var setUserNameResult = await _userManager.SetUserNameAsync(user, email);
+            if (!setUserNameResult.Succeeded)
+            {
+                StatusMessage = "Error changing user name.";
+                return Page();
+            }
+
+            await _signInManager.RefreshSignInAsync(user);
+            StatusMessage = "Thank you for confirming your email change.";
+            return Page();
+        }
+    }
+}

+ 33 - 0
Admin/Areas/Identity/Pages/Account/ExternalLogin.cshtml

@@ -0,0 +1,33 @@
+@page
+@model ExternalLoginModel
+@{
+    ViewData["Title"] = "Register";
+}
+
+<h1>@ViewData["Title"]</h1>
+<h2 id="external-login-title">Associate your @Model.ProviderDisplayName account.</h2>
+<hr />
+
+<p id="external-login-description" class="text-info">
+    You've successfully authenticated with <strong>@Model.ProviderDisplayName</strong>.
+    Please enter an email address for this site below and click the Register button to finish
+    logging in.
+</p>
+
+<div class="row">
+    <div class="col-md-4">
+        <form asp-page-handler="Confirmation" asp-route-returnUrl="@Model.ReturnUrl" method="post">
+            <div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
+            <div class="form-floating mb-3">
+                <input asp-for="Input.Email" class="form-control" autocomplete="email" placeholder="Please enter your email."/>
+                <label asp-for="Input.Email" class="form-label"></label>
+                <span asp-validation-for="Input.Email" class="text-danger"></span>
+            </div>
+            <button type="submit" class="w-100 btn btn-lg btn-primary">Register</button>
+        </form>
+    </div>
+</div>
+
+@section Scripts {
+    <partial name="_ValidationScriptsPartial" />
+}

+ 224 - 0
Admin/Areas/Identity/Pages/Account/ExternalLogin.cshtml.cs

@@ -0,0 +1,224 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+#nullable disable
+
+using System;
+using System.ComponentModel.DataAnnotations;
+using System.Security.Claims;
+using System.Text;
+using System.Text.Encodings.Web;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.Extensions.Options;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Identity.UI.Services;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.WebUtilities;
+using Microsoft.Extensions.Logging;
+using Infrastructure.Persistence.Identity;
+
+namespace Admin.Areas.Identity.Pages.Account
+{
+    [AllowAnonymous]
+    public class ExternalLoginModel : PageModel
+    {
+        private readonly SignInManager<ApplicationUser> _signInManager;
+        private readonly UserManager<ApplicationUser> _userManager;
+        private readonly IUserStore<ApplicationUser> _userStore;
+        private readonly IUserEmailStore<ApplicationUser> _emailStore;
+        private readonly IEmailSender _emailSender;
+        private readonly ILogger<ExternalLoginModel> _logger;
+
+        public ExternalLoginModel(
+            SignInManager<ApplicationUser> signInManager,
+            UserManager<ApplicationUser> userManager,
+            IUserStore<ApplicationUser> userStore,
+            ILogger<ExternalLoginModel> logger,
+            IEmailSender emailSender)
+        {
+            _signInManager = signInManager;
+            _userManager = userManager;
+            _userStore = userStore;
+            _emailStore = GetEmailStore();
+            _logger = logger;
+            _emailSender = emailSender;
+        }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        [BindProperty]
+        public InputModel Input { get; set; }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public string ProviderDisplayName { get; set; }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public string ReturnUrl { get; set; }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        [TempData]
+        public string ErrorMessage { get; set; }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public class InputModel
+        {
+            /// <summary>
+            ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+            ///     directly from your code. This API may change or be removed in future releases.
+            /// </summary>
+            [Required]
+            [EmailAddress]
+            public string Email { get; set; }
+        }
+
+        public IActionResult OnGet() => RedirectToPage("./Login");
+
+        public IActionResult OnPost(string provider, string returnUrl = null)
+        {
+            // Request a redirect to the external login provider.
+            var redirectUrl = Url.Page("./ExternalLogin", pageHandler: "Callback", values: new { returnUrl });
+            var properties = _signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl);
+            return new ChallengeResult(provider, properties);
+        }
+
+        public async Task<IActionResult> OnGetCallbackAsync(string returnUrl = null, string remoteError = null)
+        {
+            returnUrl = returnUrl ?? Url.Content("~/");
+            if (remoteError != null)
+            {
+                ErrorMessage = $"Error from external provider: {remoteError}";
+                return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
+            }
+            var info = await _signInManager.GetExternalLoginInfoAsync();
+            if (info == null)
+            {
+                ErrorMessage = "Error loading external login information.";
+                return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
+            }
+
+            // Sign in the user with this external login provider if the user already has a login.
+            var result = await _signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, isPersistent: false, bypassTwoFactor: true);
+            if (result.Succeeded)
+            {
+                _logger.LogInformation("{Name} logged in with {LoginProvider} provider.", info.Principal.Identity.Name, info.LoginProvider);
+                return LocalRedirect(returnUrl);
+            }
+            if (result.IsLockedOut)
+            {
+                return RedirectToPage("./Lockout");
+            }
+            else
+            {
+                // If the user does not have an account, then ask the user to create an account.
+                ReturnUrl = returnUrl;
+                ProviderDisplayName = info.ProviderDisplayName;
+                if (info.Principal.HasClaim(c => c.Type == ClaimTypes.Email))
+                {
+                    Input = new InputModel
+                    {
+                        Email = info.Principal.FindFirstValue(ClaimTypes.Email)
+                    };
+                }
+                return Page();
+            }
+        }
+
+        public async Task<IActionResult> OnPostConfirmationAsync(string returnUrl = null)
+        {
+            returnUrl = returnUrl ?? Url.Content("~/");
+            // Get the information about the user from the external login provider
+            var info = await _signInManager.GetExternalLoginInfoAsync();
+            if (info == null)
+            {
+                ErrorMessage = "Error loading external login information during confirmation.";
+                return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
+            }
+
+            if (ModelState.IsValid)
+            {
+                var user = CreateUser();
+
+                await _userStore.SetUserNameAsync(user, Input.Email, CancellationToken.None);
+                await _emailStore.SetEmailAsync(user, Input.Email, CancellationToken.None);
+
+                var result = await _userManager.CreateAsync(user);
+                if (result.Succeeded)
+                {
+                    result = await _userManager.AddLoginAsync(user, info);
+                    if (result.Succeeded)
+                    {
+                        _logger.LogInformation("User created an account using {Name} provider.", info.LoginProvider);
+
+                        var userId = await _userManager.GetUserIdAsync(user);
+                        var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
+                        code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
+                        var callbackUrl = Url.Page(
+                            "/Account/ConfirmEmail",
+                            pageHandler: null,
+                            values: new { area = "Identity", userId = userId, code = code },
+                            protocol: Request.Scheme);
+
+                        await _emailSender.SendEmailAsync(Input.Email, "Confirm your email",
+                            $"Please confirm your account by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>.");
+
+                        // If account confirmation is required, we need to show the link if we don't have a real email sender
+                        if (_userManager.Options.SignIn.RequireConfirmedAccount)
+                        {
+                            return RedirectToPage("./RegisterConfirmation", new { Email = Input.Email });
+                        }
+
+                        await _signInManager.SignInAsync(user, isPersistent: false, info.LoginProvider);
+                        return LocalRedirect(returnUrl);
+                    }
+                }
+                foreach (var error in result.Errors)
+                {
+                    ModelState.AddModelError(string.Empty, error.Description);
+                }
+            }
+
+            ProviderDisplayName = info.ProviderDisplayName;
+            ReturnUrl = returnUrl;
+            return Page();
+        }
+
+        private ApplicationUser CreateUser()
+        {
+            try
+            {
+                return Activator.CreateInstance<ApplicationUser>();
+            }
+            catch
+            {
+                throw new InvalidOperationException($"Can't create an instance of '{nameof(ApplicationUser)}'. " +
+                    $"Ensure that '{nameof(ApplicationUser)}' is not an abstract class and has a parameterless constructor, or alternatively " +
+                    $"override the external login page in /Areas/Identity/Pages/Account/ExternalLogin.cshtml");
+            }
+        }
+
+        private IUserEmailStore<ApplicationUser> GetEmailStore()
+        {
+            if (!_userManager.SupportsUserEmail)
+            {
+                throw new NotSupportedException("The default UI requires a user store with email support.");
+            }
+            return (IUserEmailStore<ApplicationUser>)_userStore;
+        }
+    }
+}

+ 42 - 0
Admin/Areas/Identity/Pages/Account/ForgotPassword.cshtml

@@ -0,0 +1,42 @@
+@page
+@model ForgotPasswordModel
+@{
+    ViewData["Title"] = "비밀번호를 잊으셨나요?";
+}
+
+<div id="forgotPasswordForm" class="row row-cols-1 justify-content-center align-items-center min-vh-100">
+    <div class="col col-12 col-sm-7 col-md-5 col-lg-5 col-xl-3">
+        <section>
+            <form method="post" accept-charset="utf-8" autocomplete="off">
+                <h4>@ViewData["Title"]</h4>
+                <small>재설정할 이메일을 입력하세요.</small>
+                <hr />
+
+                <div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
+                <div class="form-floating mb-3">
+                    <input asp-for="Input.Email" class="form-control" autocomplete="username" aria-required="true" placeholder="name@example.com" />
+                    <label asp-for="Input.Email" class="form-label">이메일</label>
+                    <span asp-validation-for="Input.Email" class="text-danger"></span>
+                </div>
+                <button type="submit" class="w-100 btn btn-lg btn-primary">비밀번호 재설정</button>
+                <p class="pt-3 text-center">
+                    <a asp-page="./Login">< 취소하기</a>
+                </p>
+            </form>
+        </section>
+    </div>
+    <div class="col">
+        <div class="text-center ps-3 pe-3">
+            <hr />
+            <small>ⓒ PLAYR. All Rights Reserved</small>
+        </div>
+    </div>
+</div>
+
+@section Scripts {
+    <partial name="_ValidationScriptsPartial" />
+}
+
+@section Styles {
+    <link rel="stylesheet" href="~/css/account.css" asp-append-version="true" />
+}

+ 85 - 0
Admin/Areas/Identity/Pages/Account/ForgotPassword.cshtml.cs

@@ -0,0 +1,85 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+#nullable disable
+
+using Infrastructure.Persistence.Identity;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Identity.UI.Services;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.WebUtilities;
+using System;
+using System.ComponentModel.DataAnnotations;
+using System.Text;
+using System.Text.Encodings.Web;
+using System.Threading.Tasks;
+
+namespace Admin.Areas.Identity.Pages.Account
+{
+    public class ForgotPasswordModel : PageModel
+    {
+        private readonly UserManager<ApplicationUser> _userManager;
+        private readonly IEmailSender _emailSender;
+
+        public ForgotPasswordModel(UserManager<ApplicationUser> userManager, IEmailSender emailSender)
+        {
+            _userManager = userManager;
+            _emailSender = emailSender;
+        }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        [BindProperty]
+        public InputModel Input { get; set; }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public class InputModel
+        {
+            /// <summary>
+            ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+            ///     directly from your code. This API may change or be removed in future releases.
+            /// </summary>
+            [Required]
+            [EmailAddress]
+            public string Email { get; set; }
+        }
+
+        public async Task<IActionResult> OnPostAsync()
+        {
+            if (ModelState.IsValid)
+            {
+                var user = await _userManager.FindByEmailAsync(Input.Email);
+                if (user == null || !(await _userManager.IsEmailConfirmedAsync(user)))
+                {
+                    // Don't reveal that the user does not exist or is not confirmed
+                    return RedirectToPage("./ForgotPasswordConfirmation");
+                }
+
+                // For more information on how to enable account confirmation and password reset please
+                // visit https://go.microsoft.com/fwlink/?LinkID=532713
+                var code = await _userManager.GeneratePasswordResetTokenAsync(user);
+                code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
+                var callbackUrl = Url.Page(
+                    "/Account/ResetPassword",
+                    pageHandler: null,
+                    values: new { area = "Identity", code },
+                    protocol: Request.Scheme);
+
+                await _emailSender.SendEmailAsync(
+                    Input.Email,
+                    "Reset Password",
+                    $"Please reset your password by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>.");
+
+                return RedirectToPage("./ForgotPasswordConfirmation");
+            }
+
+            return Page();
+        }
+    }
+}

+ 19 - 0
Admin/Areas/Identity/Pages/Account/ForgotPasswordConfirmation.cshtml

@@ -0,0 +1,19 @@
+@page
+@model ForgotPasswordConfirmation
+@{
+    ViewData["Title"] = "Forgot password confirmation";
+}
+
+@*
+<h1>@ViewData["Title"]</h1>
+<p>
+    Please check your email to reset your password.
+</p>
+*@
+
+<script>
+    setTimeout(() => {
+        alert("받은 메일함을 확인하고 비밀번호를 변경하세요.");
+        window.location.replace("/Identity/Account/Login");
+    }, 0);
+</script>

+ 25 - 0
Admin/Areas/Identity/Pages/Account/ForgotPasswordConfirmation.cshtml.cs

@@ -0,0 +1,25 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+#nullable disable
+
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace Admin.Areas.Identity.Pages.Account
+{
+    /// <summary>
+    ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+    ///     directly from your code. This API may change or be removed in future releases.
+    /// </summary>
+    [AllowAnonymous]
+    public class ForgotPasswordConfirmation : PageModel
+    {
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public void OnGet()
+        {
+        }
+    }
+}

+ 10 - 0
Admin/Areas/Identity/Pages/Account/Lockout.cshtml

@@ -0,0 +1,10 @@
+@page
+@model LockoutModel
+@{
+    ViewData["Title"] = "Locked out";
+}
+
+<header>
+    <h1 class="text-danger">@ViewData["Title"]</h1>
+    <p class="text-danger">This account has been locked out, please try again later.</p>
+</header>

+ 25 - 0
Admin/Areas/Identity/Pages/Account/Lockout.cshtml.cs

@@ -0,0 +1,25 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+#nullable disable
+
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace Admin.Areas.Identity.Pages.Account
+{
+    /// <summary>
+    ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+    ///     directly from your code. This API may change or be removed in future releases.
+    /// </summary>
+    [AllowAnonymous]
+    public class LockoutModel : PageModel
+    {
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public void OnGet()
+        {
+        }
+    }
+}

+ 95 - 0
Admin/Areas/Identity/Pages/Account/Login.cshtml

@@ -0,0 +1,95 @@
+@page
+@model LoginModel
+
+@{
+    ViewData["Title"] = "로그인";
+}
+
+<div id="loginForm" class="row row-cols-1 justify-content-center align-items-center min-vh-100">
+    <div class="col-md-4">
+        <section>
+            <h4>@ViewData["Title"]</h4>
+
+            <form id="account" method="post" accept-charset="utf-8" autocomplete="off">
+                <small>승인된 관계자만 접속이 가능합니다.</small>
+                <hr />
+                <div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
+                <div class="form-floating mb-3">
+                    <input asp-for="Input.Email" class="form-control" autocomplete="username" aria-required="true" placeholder="name@example.com" />
+                    <label asp-for="Input.Email" class="form-label">이메일</label>
+                    <span asp-validation-for="Input.Email" class="text-danger"></span>
+                </div>
+                <div class="form-floating mb-3">
+                    <input asp-for="Input.Password" class="form-control" autocomplete="current-password" aria-required="true" placeholder="password" />
+                    <label asp-for="Input.Password" class="form-label">비밀번호</label>
+                    <span asp-validation-for="Input.Password" class="text-danger"></span>
+                </div>
+                <div class="checkbox mb-3">
+                    <label asp-for="Input.RememberMe" class="form-label">
+                        <input class="form-check-input" asp-for="Input.RememberMe" />
+                        @Html.DisplayNameFor(m => m.Input.RememberMe)
+                    </label>
+                </div>
+                <div>
+                    <button id="login-submit" type="submit" class="w-100 btn btn-lg btn-primary">로그인</button>
+                </div>
+                <div>
+                    <p class="pt-3">
+                        <a id="forgot-password" asp-page="./ForgotPassword">비밀번호를 잊으셨나요?</a>
+                    </p>
+                    <p>
+                        <a asp-page="./Register" asp-route-returnUrl="@Model.ReturnUrl">회원가입</a>
+                    </p>
+                    <p>
+                        <a id="resend-confirmation" asp-page="./ResendEmailConfirmation">이메일 재인증</a>
+                    </p>
+                </div>
+            </form>
+        </section>
+    </div>
+    <div class="col">
+        <div class="text-center ps-3 pe-3">
+            <hr />
+            <small>ⓒ PLAYR. All Rights Reserved</small>
+        </div>
+
+        @*
+       <section>
+            <h3>Use another service to log in.</h3>
+            <hr />
+            @{
+                if ((Model.ExternalLogins?.Count ?? 0) == 0)
+                {
+                    <div>
+                        <p>
+                            There are no external authentication services configured. See this <a href="https://go.microsoft.com/fwlink/?LinkID=532715">article
+                            about setting up this ASP.NET application to support logging in via external services</a>.
+                        </p>
+                    </div>
+                }
+                else
+                {
+                    <form id="external-account" asp-page="./ExternalLogin" asp-route-returnUrl="@Model.ReturnUrl" method="post" class="form-horizontal">
+                        <div>
+                            <p>
+                                @foreach (var provider in Model.ExternalLogins!)
+                                {
+                                    <button type="submit" class="btn btn-primary" name="provider" value="@provider.Name" title="Log in using your @provider.DisplayName account">@provider.DisplayName</button>
+                                }
+                            </p>
+                        </div>
+                    </form>
+                }
+            }
+        </section>
+        *@
+    </div>
+</div>
+
+@section Scripts {
+    <partial name="_ValidationScriptsPartial" />
+}
+
+@section Styles {
+    <link rel="stylesheet" href="~/css/account.css" asp-append-version="true" />
+}

+ 164 - 0
Admin/Areas/Identity/Pages/Account/Login.cshtml.cs

@@ -0,0 +1,164 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+#nullable disable
+
+using Application.Abstractions.Data;
+using Domain.Entities.Director;
+using Infrastructure.Persistence.Identity;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Identity.UI.Services;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.Extensions.Logging;
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.Linq;
+using System.Threading.Tasks;
+
+namespace Admin.Areas.Identity.Pages.Account
+{
+    public class LoginModel : PageModel
+    {
+        private readonly SignInManager<ApplicationUser> _signInManager;
+        private readonly ILogger<LoginModel> _logger;
+        private readonly IAppDbContext _db;
+
+        public LoginModel(SignInManager<ApplicationUser> signInManager, ILogger<LoginModel> logger, IAppDbContext db)
+        {
+            _signInManager = signInManager;
+            _logger = logger;
+            _db = db;
+        }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        [BindProperty]
+        public InputModel Input { get; set; }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public IList<AuthenticationScheme> ExternalLogins { get; set; }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public string ReturnUrl { get; set; }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        [TempData]
+        public string ErrorMessage { get; set; }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public class InputModel
+        {
+            /// <summary>
+            ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+            ///     directly from your code. This API may change or be removed in future releases.
+            /// </summary>
+            [Required]
+            [EmailAddress]
+            public string Email { get; set; }
+
+            /// <summary>
+            ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+            ///     directly from your code. This API may change or be removed in future releases.
+            /// </summary>
+            [Required]
+            [DataType(DataType.Password)]
+            public string Password { get; set; }
+
+            /// <summary>
+            ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+            ///     directly from your code. This API may change or be removed in future releases.
+            /// </summary>
+            [Display(Name = "로그인 상태 유지")]
+            public bool RememberMe { get; set; }
+        }
+
+        public async Task OnGetAsync(string returnUrl = null)
+        {
+            if (!string.IsNullOrEmpty(ErrorMessage))
+            {
+                ModelState.AddModelError(string.Empty, ErrorMessage);
+            }
+
+            returnUrl ??= Url.Content("~/");
+
+            // Clear the existing external cookie to ensure a clean login process
+            await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme);
+
+            ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToList();
+
+            ReturnUrl = returnUrl;
+        }
+
+        public async Task<IActionResult> OnPostAsync(string returnUrl = null)
+        {
+            returnUrl ??= Url.Content("~/");
+
+            ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToList();
+
+            if (ModelState.IsValid)
+            {
+                var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
+                var userAgent = HttpContext.Request.Headers.UserAgent.ToString();
+                if (userAgent?.Length > 512)
+                {
+                    userAgent = userAgent[..512];
+                }
+
+                // This doesn't count login failures towards account lockout
+                // To enable password failures to trigger account lockout, set lockoutOnFailure: true
+                var result = await _signInManager.PasswordSignInAsync(Input.Email, Input.Password, Input.RememberMe, lockoutOnFailure: false);
+                if (result.Succeeded)
+                {
+                    await _db.AdminLoginLog.AddAsync(AdminLoginLog.Create(true, Input.Email, ipAddress: ipAddress, userAgent: userAgent));
+                    await _db.SaveChangesAsync();
+
+                    _logger.LogInformation("User logged in.");
+                    return LocalRedirect(returnUrl);
+                }
+                if (result.RequiresTwoFactor)
+                {
+                    await _db.AdminLoginLog.AddAsync(AdminLoginLog.Create(true, Input.Email, reason: "2FA 필요", ipAddress: ipAddress, userAgent: userAgent));
+                    await _db.SaveChangesAsync();
+
+                    return RedirectToPage("./LoginWith2fa", new { ReturnUrl = returnUrl, RememberMe = Input.RememberMe });
+                }
+                if (result.IsLockedOut)
+                {
+                    await _db.AdminLoginLog.AddAsync(AdminLoginLog.Create(false, Input.Email, reason: "계정 잠김", ipAddress: ipAddress, userAgent: userAgent));
+                    await _db.SaveChangesAsync();
+
+                    _logger.LogWarning("User account locked out.");
+                    return RedirectToPage("./Lockout");
+                }
+                else
+                {
+                    await _db.AdminLoginLog.AddAsync(AdminLoginLog.Create(false, Input.Email, reason: "로그인 실패", ipAddress: ipAddress, userAgent: userAgent));
+                    await _db.SaveChangesAsync();
+
+                    ModelState.AddModelError(string.Empty, "Invalid login attempt.");
+                    return Page();
+                }
+            }
+
+            // If we got this far, something failed, redisplay form
+            return Page();
+        }
+    }
+}

+ 39 - 0
Admin/Areas/Identity/Pages/Account/LoginWith2fa.cshtml

@@ -0,0 +1,39 @@
+@page
+@model LoginWith2faModel
+@{
+    ViewData["Title"] = "Two-factor authentication";
+}
+
+<h1>@ViewData["Title"]</h1>
+<hr />
+<p>Your login is protected with an authenticator app. Enter your authenticator code below.</p>
+<div class="row">
+    <div class="col-md-4">
+        <form method="post" asp-route-returnUrl="@Model.ReturnUrl">
+            <input asp-for="RememberMe" type="hidden" />
+            <div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
+            <div class="form-floating mb-3">
+                <input asp-for="Input.TwoFactorCode" class="form-control" autocomplete="off" />
+                <label asp-for="Input.TwoFactorCode" class="form-label"></label>
+                <span asp-validation-for="Input.TwoFactorCode" class="text-danger"></span>
+            </div>
+            <div class="checkbox mb-3">
+                <label asp-for="Input.RememberMachine" class="form-label">
+                    <input asp-for="Input.RememberMachine" />
+                    @Html.DisplayNameFor(m => m.Input.RememberMachine)
+                </label>
+            </div>
+            <div>
+                <button type="submit" class="w-100 btn btn-lg btn-primary">Log in</button>
+            </div>
+        </form>
+    </div>
+</div>
+<p>
+    Don't have access to your authenticator device? You can
+    <a id="recovery-code-login" asp-page="./LoginWithRecoveryCode" asp-route-returnUrl="@Model.ReturnUrl">log in with a recovery code</a>.
+</p>
+
+@section Scripts {
+    <partial name="_ValidationScriptsPartial" />
+}

+ 132 - 0
Admin/Areas/Identity/Pages/Account/LoginWith2fa.cshtml.cs

@@ -0,0 +1,132 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+#nullable disable
+
+using Infrastructure.Persistence.Identity;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging;
+using System;
+using System.ComponentModel.DataAnnotations;
+using System.Threading.Tasks;
+
+namespace Admin.Areas.Identity.Pages.Account
+{
+    public class LoginWith2faModel : PageModel
+    {
+        private readonly SignInManager<ApplicationUser> _signInManager;
+        private readonly UserManager<ApplicationUser> _userManager;
+        private readonly ILogger<LoginWith2faModel> _logger;
+
+        public LoginWith2faModel(
+            SignInManager<ApplicationUser> signInManager,
+            UserManager<ApplicationUser> userManager,
+            ILogger<LoginWith2faModel> logger)
+        {
+            _signInManager = signInManager;
+            _userManager = userManager;
+            _logger = logger;
+        }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        [BindProperty]
+        public InputModel Input { get; set; }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public bool RememberMe { get; set; }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public string ReturnUrl { get; set; }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public class InputModel
+        {
+            /// <summary>
+            ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+            ///     directly from your code. This API may change or be removed in future releases.
+            /// </summary>
+            [Required]
+            [StringLength(7, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
+            [DataType(DataType.Text)]
+            [Display(Name = "Authenticator code")]
+            public string TwoFactorCode { get; set; }
+
+            /// <summary>
+            ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+            ///     directly from your code. This API may change or be removed in future releases.
+            /// </summary>
+            [Display(Name = "Remember this machine")]
+            public bool RememberMachine { get; set; }
+        }
+
+        public async Task<IActionResult> OnGetAsync(bool rememberMe, string returnUrl = null)
+        {
+            // Ensure the user has gone through the username & password screen first
+            var user = await _signInManager.GetTwoFactorAuthenticationUserAsync();
+
+            if (user == null)
+            {
+                throw new InvalidOperationException($"Unable to load two-factor authentication user.");
+            }
+
+            ReturnUrl = returnUrl;
+            RememberMe = rememberMe;
+
+            return Page();
+        }
+
+        public async Task<IActionResult> OnPostAsync(bool rememberMe, string returnUrl = null)
+        {
+            if (!ModelState.IsValid)
+            {
+                return Page();
+            }
+
+            returnUrl = returnUrl ?? Url.Content("~/");
+
+            var user = await _signInManager.GetTwoFactorAuthenticationUserAsync();
+            if (user == null)
+            {
+                throw new InvalidOperationException($"Unable to load two-factor authentication user.");
+            }
+
+            var authenticatorCode = Input.TwoFactorCode.Replace(" ", string.Empty).Replace("-", string.Empty);
+
+            var result = await _signInManager.TwoFactorAuthenticatorSignInAsync(authenticatorCode, rememberMe, Input.RememberMachine);
+
+            var userId = await _userManager.GetUserIdAsync(user);
+
+            if (result.Succeeded)
+            {
+                _logger.LogInformation("User with ID '{UserId}' logged in with 2fa.", user.Id);
+                return LocalRedirect(returnUrl);
+            }
+            else if (result.IsLockedOut)
+            {
+                _logger.LogWarning("User with ID '{UserId}' account locked out.", user.Id);
+                return RedirectToPage("./Lockout");
+            }
+            else
+            {
+                _logger.LogWarning("Invalid authenticator code entered for user with ID '{UserId}'.", user.Id);
+                ModelState.AddModelError(string.Empty, "Invalid authenticator code.");
+                return Page();
+            }
+        }
+    }
+}

+ 29 - 0
Admin/Areas/Identity/Pages/Account/LoginWithRecoveryCode.cshtml

@@ -0,0 +1,29 @@
+@page
+@model LoginWithRecoveryCodeModel
+@{
+    ViewData["Title"] = "Recovery code verification";
+}
+
+<h1>@ViewData["Title"]</h1>
+<hr />
+<p>
+    You have requested to log in with a recovery code. This login will not be remembered until you provide
+    an authenticator app code at log in or disable 2FA and log in again.
+</p>
+<div class="row">
+    <div class="col-md-4">
+        <form method="post">
+            <div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
+            <div class="form-floating mb-3">
+                <input asp-for="Input.RecoveryCode" class="form-control" autocomplete="off" placeholder="RecoveryCode" />
+                <label asp-for="Input.RecoveryCode" class="form-label"></label>
+                <span asp-validation-for="Input.RecoveryCode" class="text-danger"></span>
+            </div>
+            <button type="submit" class="w-100 btn btn-lg btn-primary">Log in</button>
+        </form>
+    </div>
+</div>
+
+@section Scripts {
+    <partial name="_ValidationScriptsPartial" />
+}

+ 113 - 0
Admin/Areas/Identity/Pages/Account/LoginWithRecoveryCode.cshtml.cs

@@ -0,0 +1,113 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+#nullable disable
+
+using Infrastructure.Persistence.Identity;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.Extensions.Logging;
+using System;
+using System.ComponentModel.DataAnnotations;
+using System.Threading.Tasks;
+namespace Admin.Areas.Identity.Pages.Account
+{
+    public class LoginWithRecoveryCodeModel : PageModel
+    {
+        private readonly SignInManager<ApplicationUser> _signInManager;
+        private readonly UserManager<ApplicationUser> _userManager;
+        private readonly ILogger<LoginWithRecoveryCodeModel> _logger;
+
+        public LoginWithRecoveryCodeModel(
+            SignInManager<ApplicationUser> signInManager,
+            UserManager<ApplicationUser> userManager,
+            ILogger<LoginWithRecoveryCodeModel> logger)
+        {
+            _signInManager = signInManager;
+            _userManager = userManager;
+            _logger = logger;
+        }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        [BindProperty]
+        public InputModel Input { get; set; }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public string ReturnUrl { get; set; }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public class InputModel
+        {
+            /// <summary>
+            ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+            ///     directly from your code. This API may change or be removed in future releases.
+            /// </summary>
+            [BindProperty]
+            [Required]
+            [DataType(DataType.Text)]
+            [Display(Name = "Recovery Code")]
+            public string RecoveryCode { get; set; }
+        }
+
+        public async Task<IActionResult> OnGetAsync(string returnUrl = null)
+        {
+            // Ensure the user has gone through the username & password screen first
+            var user = await _signInManager.GetTwoFactorAuthenticationUserAsync();
+            if (user == null)
+            {
+                throw new InvalidOperationException($"Unable to load two-factor authentication user.");
+            }
+
+            ReturnUrl = returnUrl;
+
+            return Page();
+        }
+
+        public async Task<IActionResult> OnPostAsync(string returnUrl = null)
+        {
+            if (!ModelState.IsValid)
+            {
+                return Page();
+            }
+
+            var user = await _signInManager.GetTwoFactorAuthenticationUserAsync();
+            if (user == null)
+            {
+                throw new InvalidOperationException($"Unable to load two-factor authentication user.");
+            }
+
+            var recoveryCode = Input.RecoveryCode.Replace(" ", string.Empty);
+
+            var result = await _signInManager.TwoFactorRecoveryCodeSignInAsync(recoveryCode);
+
+            var userId = await _userManager.GetUserIdAsync(user);
+
+            if (result.Succeeded)
+            {
+                _logger.LogInformation("User with ID '{UserId}' logged in with a recovery code.", user.Id);
+                return LocalRedirect(returnUrl ?? Url.Content("~/"));
+            }
+            if (result.IsLockedOut)
+            {
+                _logger.LogWarning("User account locked out.");
+                return RedirectToPage("./Lockout");
+            }
+            else
+            {
+                _logger.LogWarning("Invalid recovery code entered for user with ID '{UserId}' ", user.Id);
+                ModelState.AddModelError(string.Empty, "Invalid recovery code entered.");
+                return Page();
+            }
+        }
+    }
+}

+ 21 - 0
Admin/Areas/Identity/Pages/Account/Logout.cshtml

@@ -0,0 +1,21 @@
+@page
+@model LogoutModel
+@{
+    ViewData["Title"] = "Log out";
+}
+
+<header>
+    <h1>@ViewData["Title"]</h1>
+    @{
+        if (User.Identity?.IsAuthenticated ?? false)
+        {
+            <form class="form-inline" asp-area="Identity" asp-page="/Account/Logout" asp-route-returnUrl="@Url.Page("/", new { area = "" })" method="post">
+                <button type="submit" class="nav-link btn btn-link text-dark">Click here to Logout</button>
+            </form>
+        }
+        else
+        {
+            <p>You have successfully logged out of the application.</p>
+        }
+    }
+</header>

+ 43 - 0
Admin/Areas/Identity/Pages/Account/Logout.cshtml.cs

@@ -0,0 +1,43 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+#nullable disable
+
+using System;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.Extensions.Logging;
+using Infrastructure.Persistence.Identity;
+
+namespace Admin.Areas.Identity.Pages.Account
+{
+    public class LogoutModel : PageModel
+    {
+        private readonly SignInManager<ApplicationUser> _signInManager;
+        private readonly ILogger<LogoutModel> _logger;
+
+        public LogoutModel(SignInManager<ApplicationUser> signInManager, ILogger<LogoutModel> logger)
+        {
+            _signInManager = signInManager;
+            _logger = logger;
+        }
+
+        public async Task<IActionResult> OnPost(string returnUrl = null)
+        {
+            await _signInManager.SignOutAsync();
+            _logger.LogInformation("User logged out.");
+            if (returnUrl != null)
+            {
+                return LocalRedirect(returnUrl);
+            }
+            else
+            {
+                // This needs to be a redirect so that the browser performs a new
+                // request and the identity for the user gets updated.
+                return RedirectToPage();
+            }
+        }
+    }
+}

+ 36 - 0
Admin/Areas/Identity/Pages/Account/Manage/ChangePassword.cshtml

@@ -0,0 +1,36 @@
+@page
+@model ChangePasswordModel
+@{
+    ViewData["Title"] = "비밀번호 변경";
+    ViewData["ActivePage"] = ManageNavPages.ChangePassword;
+}
+
+<h3 class="pb-2">@ViewData["Title"]</h3>
+<partial name="_StatusMessage" for="StatusMessage" />
+<div class="row">
+    <div class="col-md-6">
+        <form id="change-password-form" method="post">
+            <div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
+            <div class="form-floating mb-3">
+                <input asp-for="Input.OldPassword" class="form-control" autocomplete="current-password" aria-required="true" placeholder="Please enter your old password." />
+                <label asp-for="Input.OldPassword" class="form-label">현재 비밀번호</label>
+                <span asp-validation-for="Input.OldPassword" class="text-danger"></span>
+            </div>
+            <div class="form-floating mb-3">
+                <input asp-for="Input.NewPassword" class="form-control" autocomplete="new-password" aria-required="true" placeholder="Please enter your new password." />
+                <label asp-for="Input.NewPassword" class="form-label">새 비밀번호</label>
+                <span asp-validation-for="Input.NewPassword" class="text-danger"></span>
+            </div>
+            <div class="form-floating mb-3">
+                <input asp-for="Input.ConfirmPassword" class="form-control" autocomplete="new-password" aria-required="true" placeholder="Please confirm your new password."/>
+                <label asp-for="Input.ConfirmPassword" class="form-label">새 비밀번호 재입력</label>
+                <span asp-validation-for="Input.ConfirmPassword" class="text-danger"></span>
+            </div>
+            <button type="submit" class="w-100 btn btn-lg btn-primary">변경하기</button>
+        </form>
+    </div>
+</div>
+
+@section Scripts {
+    <partial name="_ValidationScriptsPartial" />
+}

+ 128 - 0
Admin/Areas/Identity/Pages/Account/Manage/ChangePassword.cshtml.cs

@@ -0,0 +1,128 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+#nullable disable
+
+using System;
+using System.ComponentModel.DataAnnotations;
+using System.Threading.Tasks;
+using Infrastructure.Persistence.Identity;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.Extensions.Logging;
+
+namespace Admin.Areas.Identity.Pages.Account.Manage
+{
+    public class ChangePasswordModel : PageModel
+    {
+        private readonly UserManager<ApplicationUser> _userManager;
+        private readonly SignInManager<ApplicationUser> _signInManager;
+        private readonly ILogger<ChangePasswordModel> _logger;
+
+        public ChangePasswordModel(
+            UserManager<ApplicationUser> userManager,
+            SignInManager<ApplicationUser> signInManager,
+            ILogger<ChangePasswordModel> logger)
+        {
+            _userManager = userManager;
+            _signInManager = signInManager;
+            _logger = logger;
+        }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        [BindProperty]
+        public InputModel Input { get; set; }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        [TempData]
+        public string StatusMessage { get; set; }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public class InputModel
+        {
+            /// <summary>
+            ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+            ///     directly from your code. This API may change or be removed in future releases.
+            /// </summary>
+            [Required]
+            [DataType(DataType.Password)]
+            [Display(Name = "Current password")]
+            public string OldPassword { get; set; }
+
+            /// <summary>
+            ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+            ///     directly from your code. This API may change or be removed in future releases.
+            /// </summary>
+            [Required]
+            [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
+            [DataType(DataType.Password)]
+            [Display(Name = "New password")]
+            public string NewPassword { get; set; }
+
+            /// <summary>
+            ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+            ///     directly from your code. This API may change or be removed in future releases.
+            /// </summary>
+            [DataType(DataType.Password)]
+            [Display(Name = "Confirm new password")]
+            [Compare("NewPassword", ErrorMessage = "The new password and confirmation password do not match.")]
+            public string ConfirmPassword { get; set; }
+        }
+
+        public async Task<IActionResult> OnGetAsync()
+        {
+            var user = await _userManager.GetUserAsync(User);
+            if (user == null)
+            {
+                return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
+            }
+
+            var hasPassword = await _userManager.HasPasswordAsync(user);
+            if (!hasPassword)
+            {
+                return RedirectToPage("./SetPassword");
+            }
+
+            return Page();
+        }
+
+        public async Task<IActionResult> OnPostAsync()
+        {
+            if (!ModelState.IsValid)
+            {
+                return Page();
+            }
+
+            var user = await _userManager.GetUserAsync(User);
+            if (user == null)
+            {
+                return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
+            }
+
+            var changePasswordResult = await _userManager.ChangePasswordAsync(user, Input.OldPassword, Input.NewPassword);
+            if (!changePasswordResult.Succeeded)
+            {
+                foreach (var error in changePasswordResult.Errors)
+                {
+                    ModelState.AddModelError(string.Empty, error.Description);
+                }
+                return Page();
+            }
+
+            await _signInManager.RefreshSignInAsync(user);
+            _logger.LogInformation("User changed their password successfully.");
+            StatusMessage = "Your password has been changed.";
+
+            return RedirectToPage();
+        }
+    }
+}

+ 33 - 0
Admin/Areas/Identity/Pages/Account/Manage/DeletePersonalData.cshtml

@@ -0,0 +1,33 @@
+@page
+@model DeletePersonalDataModel
+@{
+    ViewData["Title"] = "Delete Personal Data";
+    ViewData["ActivePage"] = ManageNavPages.PersonalData;
+}
+
+<h3>@ViewData["Title"]</h3>
+
+<div class="alert alert-warning" role="alert">
+    <p>
+        <strong>Deleting this data will permanently remove your account, and this cannot be recovered.</strong>
+    </p>
+</div>
+
+<div>
+    <form id="delete-user" method="post">
+        <div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
+        @if (Model.RequirePassword)
+        {
+            <div class="form-floating mb-3">
+                <input asp-for="Input.Password" class="form-control" autocomplete="current-password" aria-required="true" placeholder="Please enter your password." />
+                <label asp-for="Input.Password" class="form-label"></label>
+                <span asp-validation-for="Input.Password" class="text-danger"></span>
+            </div>
+        }
+        <button class="w-100 btn btn-lg btn-danger" type="submit">Delete data and close my account</button>
+    </form>
+</div>
+
+@section Scripts {
+    <partial name="_ValidationScriptsPartial" />
+}

+ 104 - 0
Admin/Areas/Identity/Pages/Account/Manage/DeletePersonalData.cshtml.cs

@@ -0,0 +1,104 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+#nullable disable
+
+using Infrastructure.Persistence.Identity;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.Extensions.Logging;
+using System;
+using System.ComponentModel.DataAnnotations;
+using System.Threading.Tasks;
+
+namespace Admin.Areas.Identity.Pages.Account.Manage
+{
+    public class DeletePersonalDataModel : PageModel
+    {
+        private readonly UserManager<ApplicationUser> _userManager;
+        private readonly SignInManager<ApplicationUser> _signInManager;
+        private readonly ILogger<DeletePersonalDataModel> _logger;
+
+        public DeletePersonalDataModel(
+            UserManager<ApplicationUser> userManager,
+            SignInManager<ApplicationUser> signInManager,
+            ILogger<DeletePersonalDataModel> logger)
+        {
+            _userManager = userManager;
+            _signInManager = signInManager;
+            _logger = logger;
+        }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        [BindProperty]
+        public InputModel Input { get; set; }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public class InputModel
+        {
+            /// <summary>
+            ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+            ///     directly from your code. This API may change or be removed in future releases.
+            /// </summary>
+            [Required]
+            [DataType(DataType.Password)]
+            public string Password { get; set; }
+        }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public bool RequirePassword { get; set; }
+
+        public async Task<IActionResult> OnGet()
+        {
+            var user = await _userManager.GetUserAsync(User);
+            if (user == null)
+            {
+                return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
+            }
+
+            RequirePassword = await _userManager.HasPasswordAsync(user);
+            return Page();
+        }
+
+        public async Task<IActionResult> OnPostAsync()
+        {
+            var user = await _userManager.GetUserAsync(User);
+            if (user == null)
+            {
+                return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
+            }
+
+            RequirePassword = await _userManager.HasPasswordAsync(user);
+            if (RequirePassword)
+            {
+                if (!await _userManager.CheckPasswordAsync(user, Input.Password))
+                {
+                    ModelState.AddModelError(string.Empty, "Incorrect password.");
+                    return Page();
+                }
+            }
+
+            var result = await _userManager.DeleteAsync(user);
+            var userId = await _userManager.GetUserIdAsync(user);
+            if (!result.Succeeded)
+            {
+                throw new InvalidOperationException($"Unexpected error occurred deleting user.");
+            }
+
+            await _signInManager.SignOutAsync();
+
+            _logger.LogInformation("User with ID '{UserId}' deleted themselves.", userId);
+
+            return Redirect("~/");
+        }
+    }
+}

+ 25 - 0
Admin/Areas/Identity/Pages/Account/Manage/Disable2fa.cshtml

@@ -0,0 +1,25 @@
+@page
+@model Disable2faModel
+@{
+    ViewData["Title"] = "Disable two-factor authentication (2FA)";
+    ViewData["ActivePage"] = ManageNavPages.TwoFactorAuthentication;
+}
+
+<partial name="_StatusMessage" for="StatusMessage" />
+<h3>@ViewData["Title"]</h3>
+
+<div class="alert alert-warning" role="alert">
+    <p>
+        <strong>This action only disables 2FA.</strong>
+    </p>
+    <p>
+        Disabling 2FA does not change the keys used in authenticator apps. If you wish to change the key
+        used in an authenticator app you should <a asp-page="./ResetAuthenticator">reset your authenticator keys.</a>
+    </p>
+</div>
+
+<div>
+    <form method="post">
+        <button class="btn btn-danger" type="submit">Disable 2FA</button>
+    </form>
+</div>

+ 70 - 0
Admin/Areas/Identity/Pages/Account/Manage/Disable2fa.cshtml.cs

@@ -0,0 +1,70 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+#nullable disable
+
+using System;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.Extensions.Logging;
+using Infrastructure.Persistence.Identity;
+
+namespace Admin.Areas.Identity.Pages.Account.Manage
+{
+    public class Disable2faModel : PageModel
+    {
+        private readonly UserManager<ApplicationUser> _userManager;
+        private readonly ILogger<Disable2faModel> _logger;
+
+        public Disable2faModel(
+            UserManager<ApplicationUser> userManager,
+            ILogger<Disable2faModel> logger)
+        {
+            _userManager = userManager;
+            _logger = logger;
+        }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        [TempData]
+        public string StatusMessage { get; set; }
+
+        public async Task<IActionResult> OnGet()
+        {
+            var user = await _userManager.GetUserAsync(User);
+            if (user == null)
+            {
+                return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
+            }
+
+            if (!await _userManager.GetTwoFactorEnabledAsync(user))
+            {
+                throw new InvalidOperationException($"Cannot disable 2FA for user as it's not currently enabled.");
+            }
+
+            return Page();
+        }
+
+        public async Task<IActionResult> OnPostAsync()
+        {
+            var user = await _userManager.GetUserAsync(User);
+            if (user == null)
+            {
+                return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
+            }
+
+            var disable2faResult = await _userManager.SetTwoFactorEnabledAsync(user, false);
+            if (!disable2faResult.Succeeded)
+            {
+                throw new InvalidOperationException($"Unexpected error occurred disabling 2FA.");
+            }
+
+            _logger.LogInformation("User with ID '{UserId}' has disabled 2fa.", _userManager.GetUserId(User));
+            StatusMessage = "2fa has been disabled. You can reenable 2fa when you setup an authenticator app";
+            return RedirectToPage("./TwoFactorAuthentication");
+        }
+    }
+}

+ 12 - 0
Admin/Areas/Identity/Pages/Account/Manage/DownloadPersonalData.cshtml

@@ -0,0 +1,12 @@
+@page
+@model DownloadPersonalDataModel
+@{
+    ViewData["Title"] = "Download Your Data";
+    ViewData["ActivePage"] = ManageNavPages.PersonalData;
+}
+
+<h3>@ViewData["Title"]</h3>
+
+@section Scripts {
+    <partial name="_ValidationScriptsPartial" />
+}

+ 68 - 0
Admin/Areas/Identity/Pages/Account/Manage/DownloadPersonalData.cshtml.cs

@@ -0,0 +1,68 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+#nullable disable
+
+using Infrastructure.Persistence.Identity;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.Extensions.Logging;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Text.Json;
+using System.Threading.Tasks;
+
+namespace Admin.Areas.Identity.Pages.Account.Manage
+{
+    public class DownloadPersonalDataModel : PageModel
+    {
+        private readonly UserManager<ApplicationUser> _userManager;
+        private readonly ILogger<DownloadPersonalDataModel> _logger;
+
+        public DownloadPersonalDataModel(
+            UserManager<ApplicationUser> userManager,
+            ILogger<DownloadPersonalDataModel> logger)
+        {
+            _userManager = userManager;
+            _logger = logger;
+        }
+
+        public IActionResult OnGet()
+        {
+            return NotFound();
+        }
+
+        public async Task<IActionResult> OnPostAsync()
+        {
+            var user = await _userManager.GetUserAsync(User);
+            if (user == null)
+            {
+                return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
+            }
+
+            _logger.LogInformation("User with ID '{UserId}' asked for their personal data.", _userManager.GetUserId(User));
+
+            // Only include personal data for download
+            var personalData = new Dictionary<string, string>();
+            var personalDataProps = typeof(IdentityUser).GetProperties().Where(
+                            prop => Attribute.IsDefined(prop, typeof(PersonalDataAttribute)));
+            foreach (var p in personalDataProps)
+            {
+                personalData.Add(p.Name, p.GetValue(user)?.ToString() ?? "null");
+            }
+
+            var logins = await _userManager.GetLoginsAsync(user);
+            foreach (var l in logins)
+            {
+                personalData.Add($"{l.LoginProvider} external login provider key", l.ProviderKey);
+            }
+
+            personalData.Add($"Authenticator Key", await _userManager.GetAuthenticatorKeyAsync(user));
+
+            Response.Headers.TryAdd("Content-Disposition", "attachment; filename=PersonalData.json");
+            return new FileContentResult(JsonSerializer.SerializeToUtf8Bytes(personalData), "application/json");
+        }
+    }
+}

+ 44 - 0
Admin/Areas/Identity/Pages/Account/Manage/Email.cshtml

@@ -0,0 +1,44 @@
+@page
+@model EmailModel
+@{
+    ViewData["Title"] = "이메일 변경";
+    ViewData["ActivePage"] = ManageNavPages.Email;
+}
+
+<h3 class="pb-2">@ViewData["Title"]</h3>
+<partial name="_StatusMessage" for="StatusMessage" />
+<div class="row">
+    <div class="col-md-6">
+        <form id="email-form" method="post">
+            <div asp-validation-summary="All" class="text-danger" role="alert"></div>
+            @if (Model.IsEmailConfirmed)
+            {
+                <div class="form-floating mb-3 input-group">
+                    <input asp-for="Email" class="form-control" placeholder="Please enter your email." disabled />
+                        <div class="input-group-append">
+                            <span class="h-100 input-group-text text-success font-weight-bold">✓</span>
+                        </div>
+                    <label asp-for="Email" class="form-label"></label>
+                </div>
+            }
+            else
+            {
+                <div class="form-floating mb-3">
+                    <input asp-for="Email" class="form-control" placeholder="Please enter your email." disabled />
+                    <label asp-for="Email" class="form-label"></label>
+                    <button id="email-verification" type="submit" asp-page-handler="SendVerificationEmail" class="btn btn-link">Send verification email</button>
+                </div>
+            }
+            <div class="form-floating mb-3">
+                <input asp-for="Input.NewEmail" class="form-control" autocomplete="email" aria-required="true" placeholder="Please enter new email." />
+                <label asp-for="Input.NewEmail" class="form-label"></label>
+                <span asp-validation-for="Input.NewEmail" class="text-danger"></span>
+            </div>
+            <button id="change-email-button" type="submit" asp-page-handler="ChangeEmail" class="w-100 btn btn-lg btn-primary">변경하기</button>
+        </form>
+    </div>
+</div>
+
+@section Scripts {
+    <partial name="_ValidationScriptsPartial" />
+}

+ 172 - 0
Admin/Areas/Identity/Pages/Account/Manage/Email.cshtml.cs

@@ -0,0 +1,172 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+#nullable disable
+
+using Infrastructure.Persistence.Identity;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Identity.UI.Services;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.WebUtilities;
+using System;
+using System.ComponentModel.DataAnnotations;
+using System.Text;
+using System.Text.Encodings.Web;
+using System.Threading.Tasks;
+
+namespace Admin.Areas.Identity.Pages.Account.Manage
+{
+    public class EmailModel : PageModel
+    {
+        private readonly UserManager<ApplicationUser> _userManager;
+        private readonly SignInManager<ApplicationUser> _signInManager;
+        private readonly IEmailSender _emailSender;
+
+        public EmailModel(
+            UserManager<ApplicationUser> userManager,
+            SignInManager<ApplicationUser> signInManager,
+            IEmailSender emailSender)
+        {
+            _userManager = userManager;
+            _signInManager = signInManager;
+            _emailSender = emailSender;
+        }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public string Email { get; set; }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public bool IsEmailConfirmed { get; set; }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        [TempData]
+        public string StatusMessage { get; set; }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        [BindProperty]
+        public InputModel Input { get; set; }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public class InputModel
+        {
+            /// <summary>
+            ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+            ///     directly from your code. This API may change or be removed in future releases.
+            /// </summary>
+            [Required]
+            [EmailAddress]
+            [Display(Name = "New email")]
+            public string NewEmail { get; set; }
+        }
+
+        private async Task LoadAsync(ApplicationUser user)
+        {
+            var email = await _userManager.GetEmailAsync(user);
+            Email = email;
+
+            Input = new InputModel
+            {
+                NewEmail = email,
+            };
+
+            IsEmailConfirmed = await _userManager.IsEmailConfirmedAsync(user);
+        }
+
+        public async Task<IActionResult> OnGetAsync()
+        {
+            var user = await _userManager.GetUserAsync(User);
+            if (user == null)
+            {
+                return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
+            }
+
+            await LoadAsync(user);
+            return Page();
+        }
+
+        public async Task<IActionResult> OnPostChangeEmailAsync()
+        {
+            var user = await _userManager.GetUserAsync(User);
+            if (user == null)
+            {
+                return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
+            }
+
+            if (!ModelState.IsValid)
+            {
+                await LoadAsync(user);
+                return Page();
+            }
+
+            var email = await _userManager.GetEmailAsync(user);
+            if (Input.NewEmail != email)
+            {
+                var userId = await _userManager.GetUserIdAsync(user);
+                var code = await _userManager.GenerateChangeEmailTokenAsync(user, Input.NewEmail);
+                code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
+                var callbackUrl = Url.Page(
+                    "/Account/ConfirmEmailChange",
+                    pageHandler: null,
+                    values: new { area = "Identity", userId = userId, email = Input.NewEmail, code = code },
+                    protocol: Request.Scheme);
+                await _emailSender.SendEmailAsync(
+                    Input.NewEmail,
+                    "Confirm your email",
+                    $"Please confirm your account by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>.");
+
+                StatusMessage = "Confirmation link to change email sent. Please check your email.";
+                return RedirectToPage();
+            }
+
+            StatusMessage = "Your email is unchanged.";
+            return RedirectToPage();
+        }
+
+        public async Task<IActionResult> OnPostSendVerificationEmailAsync()
+        {
+            var user = await _userManager.GetUserAsync(User);
+            if (user == null)
+            {
+                return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
+            }
+
+            if (!ModelState.IsValid)
+            {
+                await LoadAsync(user);
+                return Page();
+            }
+
+            var userId = await _userManager.GetUserIdAsync(user);
+            var email = await _userManager.GetEmailAsync(user);
+            var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
+            code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
+            var callbackUrl = Url.Page(
+                "/Account/ConfirmEmail",
+                pageHandler: null,
+                values: new { area = "Identity", userId = userId, code = code },
+                protocol: Request.Scheme);
+            await _emailSender.SendEmailAsync(
+                email,
+                "Confirm your email",
+                $"Please confirm your account by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>.");
+
+            StatusMessage = "Verification email sent. Please check your email.";
+            return RedirectToPage();
+        }
+    }
+}

+ 53 - 0
Admin/Areas/Identity/Pages/Account/Manage/EnableAuthenticator.cshtml

@@ -0,0 +1,53 @@
+@page
+@model EnableAuthenticatorModel
+@{
+    ViewData["Title"] = "Configure authenticator app";
+    ViewData["ActivePage"] = ManageNavPages.TwoFactorAuthentication;
+}
+
+<partial name="_StatusMessage" for="StatusMessage" />
+<h3>@ViewData["Title"]</h3>
+<div>
+    <p>To use an authenticator app go through the following steps:</p>
+    <ol class="list">
+        <li>
+            <p>
+                Download a two-factor authenticator app like Microsoft Authenticator for
+                <a href="https://go.microsoft.com/fwlink/?Linkid=825072">Android</a> and
+                <a href="https://go.microsoft.com/fwlink/?Linkid=825073">iOS</a> or
+                Google Authenticator for
+                <a href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2&amp;hl=en">Android</a> and
+                <a href="https://itunes.apple.com/us/app/google-authenticator/id388497605?mt=8">iOS</a>.
+            </p>
+        </li>
+        <li>
+            <p>Scan the QR Code or enter this key <kbd>@Model.SharedKey</kbd> into your two factor authenticator app. Spaces and casing do not matter.</p>
+            <div class="alert alert-info">Learn how to <a href="https://go.microsoft.com/fwlink/?Linkid=852423">enable QR code generation</a>.</div>
+            <div id="qrCode"></div>
+            <div id="qrCodeData" data-url="@Model.AuthenticatorUri"></div>
+        </li>
+        <li>
+            <p>
+                Once you have scanned the QR code or input the key above, your two factor authentication app will provide you
+                with a unique code. Enter the code in the confirmation box below.
+            </p>
+            <div class="row">
+                <div class="col-md-6">
+                    <form id="send-code" method="post">
+                        <div class="form-floating mb-3">
+                            <input asp-for="Input.Code" class="form-control" autocomplete="off" placeholder="Please enter the code."/>
+                            <label asp-for="Input.Code" class="control-label form-label">Verification Code</label>
+                            <span asp-validation-for="Input.Code" class="text-danger"></span>
+                        </div>
+                        <button type="submit" class="w-100 btn btn-lg btn-primary">Verify</button>
+                        <div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
+                    </form>
+                </div>
+            </div>
+        </li>
+    </ol>
+</div>
+
+@section Scripts {
+    <partial name="_ValidationScriptsPartial" />
+}

+ 189 - 0
Admin/Areas/Identity/Pages/Account/Manage/EnableAuthenticator.cshtml.cs

@@ -0,0 +1,189 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+#nullable disable
+
+using Infrastructure.Persistence.Identity;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.Extensions.Logging;
+using System;
+using System.ComponentModel.DataAnnotations;
+using System.Globalization;
+using System.Linq;
+using System.Text;
+using System.Text.Encodings.Web;
+using System.Threading.Tasks;
+
+namespace Admin.Areas.Identity.Pages.Account.Manage
+{
+    public class EnableAuthenticatorModel : PageModel
+    {
+        private readonly UserManager<ApplicationUser> _userManager;
+        private readonly ILogger<EnableAuthenticatorModel> _logger;
+        private readonly UrlEncoder _urlEncoder;
+
+        private const string AuthenticatorUriFormat = "otpauth://totp/{0}:{1}?secret={2}&issuer={0}&digits=6";
+
+        public EnableAuthenticatorModel(
+            UserManager<ApplicationUser> userManager,
+            ILogger<EnableAuthenticatorModel> logger,
+            UrlEncoder urlEncoder)
+        {
+            _userManager = userManager;
+            _logger = logger;
+            _urlEncoder = urlEncoder;
+        }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public string SharedKey { get; set; }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public string AuthenticatorUri { get; set; }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        [TempData]
+        public string[] RecoveryCodes { get; set; }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        [TempData]
+        public string StatusMessage { get; set; }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        [BindProperty]
+        public InputModel Input { get; set; }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public class InputModel
+        {
+            /// <summary>
+            ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+            ///     directly from your code. This API may change or be removed in future releases.
+            /// </summary>
+            [Required]
+            [StringLength(7, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
+            [DataType(DataType.Text)]
+            [Display(Name = "Verification Code")]
+            public string Code { get; set; }
+        }
+
+        public async Task<IActionResult> OnGetAsync()
+        {
+            var user = await _userManager.GetUserAsync(User);
+            if (user == null)
+            {
+                return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
+            }
+
+            await LoadSharedKeyAndQrCodeUriAsync(user);
+
+            return Page();
+        }
+
+        public async Task<IActionResult> OnPostAsync()
+        {
+            var user = await _userManager.GetUserAsync(User);
+            if (user == null)
+            {
+                return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
+            }
+
+            if (!ModelState.IsValid)
+            {
+                await LoadSharedKeyAndQrCodeUriAsync(user);
+                return Page();
+            }
+
+            // Strip spaces and hyphens
+            var verificationCode = Input.Code.Replace(" ", string.Empty).Replace("-", string.Empty);
+
+            var is2faTokenValid = await _userManager.VerifyTwoFactorTokenAsync(
+                user, _userManager.Options.Tokens.AuthenticatorTokenProvider, verificationCode);
+
+            if (!is2faTokenValid)
+            {
+                ModelState.AddModelError("Input.Code", "Verification code is invalid.");
+                await LoadSharedKeyAndQrCodeUriAsync(user);
+                return Page();
+            }
+
+            await _userManager.SetTwoFactorEnabledAsync(user, true);
+            var userId = await _userManager.GetUserIdAsync(user);
+            _logger.LogInformation("User with ID '{UserId}' has enabled 2FA with an authenticator app.", userId);
+
+            StatusMessage = "Your authenticator app has been verified.";
+
+            if (await _userManager.CountRecoveryCodesAsync(user) == 0)
+            {
+                var recoveryCodes = await _userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10);
+                RecoveryCodes = recoveryCodes.ToArray();
+                return RedirectToPage("./ShowRecoveryCodes");
+            }
+            else
+            {
+                return RedirectToPage("./TwoFactorAuthentication");
+            }
+        }
+
+        private async Task LoadSharedKeyAndQrCodeUriAsync(ApplicationUser user)
+        {
+            // Load the authenticator key & QR code URI to display on the form
+            var unformattedKey = await _userManager.GetAuthenticatorKeyAsync(user);
+            if (string.IsNullOrEmpty(unformattedKey))
+            {
+                await _userManager.ResetAuthenticatorKeyAsync(user);
+                unformattedKey = await _userManager.GetAuthenticatorKeyAsync(user);
+            }
+
+            SharedKey = FormatKey(unformattedKey);
+
+            var email = await _userManager.GetEmailAsync(user);
+            AuthenticatorUri = GenerateQrCodeUri(email, unformattedKey);
+        }
+
+        private string FormatKey(string unformattedKey)
+        {
+            var result = new StringBuilder();
+            int currentPosition = 0;
+            while (currentPosition + 4 < unformattedKey.Length)
+            {
+                result.Append(unformattedKey.AsSpan(currentPosition, 4)).Append(' ');
+                currentPosition += 4;
+            }
+            if (currentPosition < unformattedKey.Length)
+            {
+                result.Append(unformattedKey.AsSpan(currentPosition));
+            }
+
+            return result.ToString().ToLowerInvariant();
+        }
+
+        private string GenerateQrCodeUri(string email, string unformattedKey)
+        {
+            return string.Format(
+                CultureInfo.InvariantCulture,
+                AuthenticatorUriFormat,
+                _urlEncoder.Encode("Microsoft.AspNetCore.Identity.UI"),
+                _urlEncoder.Encode(email),
+                unformattedKey);
+        }
+    }
+}

+ 53 - 0
Admin/Areas/Identity/Pages/Account/Manage/ExternalLogins.cshtml

@@ -0,0 +1,53 @@
+@page
+@model ExternalLoginsModel
+@{
+    ViewData["Title"] = "Manage your external logins";
+    ViewData["ActivePage"] = ManageNavPages.ExternalLogins;
+}
+
+<partial name="_StatusMessage" for="StatusMessage" />
+@if (Model.CurrentLogins?.Count > 0)
+{
+    <h3>Registered Logins</h3>
+    <table class="table">
+        <tbody>
+            @foreach (var login in Model.CurrentLogins)
+            {
+                <tr>
+                    <td id="@($"login-provider-{login.LoginProvider}")">@login.ProviderDisplayName</td>
+                    <td>
+                        @if (Model.ShowRemoveButton)
+                        {
+                            <form id="@($"remove-login-{login.LoginProvider}")" asp-page-handler="RemoveLogin" method="post">
+                                <div>
+                                    <input asp-for="@login.LoginProvider" name="LoginProvider" type="hidden" />
+                                    <input asp-for="@login.ProviderKey" name="ProviderKey" type="hidden" />
+                                    <button type="submit" class="btn btn-primary" title="Remove this @login.ProviderDisplayName login from your account">Remove</button>
+                                </div>
+                            </form>
+                        }
+                        else
+                        {
+                            @: &nbsp;
+                        }
+                    </td>
+                </tr>
+            }
+        </tbody>
+    </table>
+}
+@if (Model.OtherLogins?.Count > 0)
+{
+    <h4>Add another service to log in.</h4>
+    <hr />
+    <form id="link-login-form" asp-page-handler="LinkLogin" method="post" class="form-horizontal">
+        <div id="socialLoginList">
+            <p>
+                @foreach (var provider in Model.OtherLogins)
+                {
+                    <button id="@($"link-login-button-{provider.Name}")" type="submit" class="btn btn-primary" name="provider" value="@provider.Name" title="Log in using your @provider.DisplayName account">@provider.DisplayName</button>
+                }
+            </p>
+        </div>
+    </form>
+}

+ 142 - 0
Admin/Areas/Identity/Pages/Account/Manage/ExternalLogins.cshtml.cs

@@ -0,0 +1,142 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+#nullable disable
+
+using Infrastructure.Persistence.Identity;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace Admin.Areas.Identity.Pages.Account.Manage
+{
+    public class ExternalLoginsModel : PageModel
+    {
+        private readonly UserManager<ApplicationUser> _userManager;
+        private readonly SignInManager<ApplicationUser> _signInManager;
+        private readonly IUserStore<ApplicationUser> _userStore;
+
+        public ExternalLoginsModel(
+            UserManager<ApplicationUser> userManager,
+            SignInManager<ApplicationUser> signInManager,
+            IUserStore<ApplicationUser> userStore)
+        {
+            _userManager = userManager;
+            _signInManager = signInManager;
+            _userStore = userStore;
+        }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public IList<UserLoginInfo> CurrentLogins { get; set; }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public IList<AuthenticationScheme> OtherLogins { get; set; }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public bool ShowRemoveButton { get; set; }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        [TempData]
+        public string StatusMessage { get; set; }
+
+        public async Task<IActionResult> OnGetAsync()
+        {
+            var user = await _userManager.GetUserAsync(User);
+            if (user == null)
+            {
+                return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
+            }
+
+            CurrentLogins = await _userManager.GetLoginsAsync(user);
+            OtherLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync())
+                .Where(auth => CurrentLogins.All(ul => auth.Name != ul.LoginProvider))
+                .ToList();
+
+            string passwordHash = null;
+            if (_userStore is IUserPasswordStore<ApplicationUser> userPasswordStore)
+            {
+                passwordHash = await userPasswordStore.GetPasswordHashAsync(user, HttpContext.RequestAborted);
+            }
+
+            ShowRemoveButton = passwordHash != null || CurrentLogins.Count > 1;
+            return Page();
+        }
+
+        public async Task<IActionResult> OnPostRemoveLoginAsync(string loginProvider, string providerKey)
+        {
+            var user = await _userManager.GetUserAsync(User);
+            if (user == null)
+            {
+                return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
+            }
+
+            var result = await _userManager.RemoveLoginAsync(user, loginProvider, providerKey);
+            if (!result.Succeeded)
+            {
+                StatusMessage = "The external login was not removed.";
+                return RedirectToPage();
+            }
+
+            await _signInManager.RefreshSignInAsync(user);
+            StatusMessage = "The external login was removed.";
+            return RedirectToPage();
+        }
+
+        public async Task<IActionResult> OnPostLinkLoginAsync(string provider)
+        {
+            // Clear the existing external cookie to ensure a clean login process
+            await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme);
+
+            // Request a redirect to the external login provider to link a login for the current user
+            var redirectUrl = Url.Page("./ExternalLogins", pageHandler: "LinkLoginCallback");
+            var properties = _signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl, _userManager.GetUserId(User));
+            return new ChallengeResult(provider, properties);
+        }
+
+        public async Task<IActionResult> OnGetLinkLoginCallbackAsync()
+        {
+            var user = await _userManager.GetUserAsync(User);
+            if (user == null)
+            {
+                return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
+            }
+
+            var userId = await _userManager.GetUserIdAsync(user);
+            var info = await _signInManager.GetExternalLoginInfoAsync(userId);
+            if (info == null)
+            {
+                throw new InvalidOperationException($"Unexpected error occurred loading external login info.");
+            }
+
+            var result = await _userManager.AddLoginAsync(user, info);
+            if (!result.Succeeded)
+            {
+                StatusMessage = "The external login was not added. External logins can only be associated with one account.";
+                return RedirectToPage();
+            }
+
+            // Clear the existing external cookie to ensure a clean login process
+            await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme);
+
+            StatusMessage = "The external login was added.";
+            return RedirectToPage();
+        }
+    }
+}

+ 27 - 0
Admin/Areas/Identity/Pages/Account/Manage/GenerateRecoveryCodes.cshtml

@@ -0,0 +1,27 @@
+@page
+@model GenerateRecoveryCodesModel
+@{
+    ViewData["Title"] = "Generate two-factor authentication (2FA) recovery codes";
+    ViewData["ActivePage"] = ManageNavPages.TwoFactorAuthentication;
+}
+
+<partial name="_StatusMessage" for="StatusMessage" />
+<h3>@ViewData["Title"]</h3>
+<div class="alert alert-warning" role="alert">
+    <p>
+        <span class="glyphicon glyphicon-warning-sign"></span>
+        <strong>Put these codes in a safe place.</strong>
+    </p>
+    <p>
+        If you lose your device and don't have the recovery codes you will lose access to your account.
+    </p>
+    <p>
+        Generating new recovery codes does not change the keys used in authenticator apps. If you wish to change the key
+        used in an authenticator app you should <a asp-page="./ResetAuthenticator">reset your authenticator keys.</a>
+    </p>
+</div>
+<div>
+    <form method="post">
+        <button class="btn btn-danger" type="submit">Generate Recovery Codes</button>
+    </form>
+</div>

+ 83 - 0
Admin/Areas/Identity/Pages/Account/Manage/GenerateRecoveryCodes.cshtml.cs

@@ -0,0 +1,83 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+#nullable disable
+
+using Infrastructure.Persistence.Identity;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.Extensions.Logging;
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+
+namespace Admin.Areas.Identity.Pages.Account.Manage
+{
+    public class GenerateRecoveryCodesModel : PageModel
+    {
+        private readonly UserManager<ApplicationUser> _userManager;
+        private readonly ILogger<GenerateRecoveryCodesModel> _logger;
+
+        public GenerateRecoveryCodesModel(
+            UserManager<ApplicationUser> userManager,
+            ILogger<GenerateRecoveryCodesModel> logger)
+        {
+            _userManager = userManager;
+            _logger = logger;
+        }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        [TempData]
+        public string[] RecoveryCodes { get; set; }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        [TempData]
+        public string StatusMessage { get; set; }
+
+        public async Task<IActionResult> OnGetAsync()
+        {
+            var user = await _userManager.GetUserAsync(User);
+            if (user == null)
+            {
+                return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
+            }
+
+            var isTwoFactorEnabled = await _userManager.GetTwoFactorEnabledAsync(user);
+            if (!isTwoFactorEnabled)
+            {
+                throw new InvalidOperationException($"Cannot generate recovery codes for user because they do not have 2FA enabled.");
+            }
+
+            return Page();
+        }
+
+        public async Task<IActionResult> OnPostAsync()
+        {
+            var user = await _userManager.GetUserAsync(User);
+            if (user == null)
+            {
+                return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
+            }
+
+            var isTwoFactorEnabled = await _userManager.GetTwoFactorEnabledAsync(user);
+            var userId = await _userManager.GetUserIdAsync(user);
+            if (!isTwoFactorEnabled)
+            {
+                throw new InvalidOperationException($"Cannot generate recovery codes for user as they do not have 2FA enabled.");
+            }
+
+            var recoveryCodes = await _userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10);
+            RecoveryCodes = recoveryCodes.ToArray();
+
+            _logger.LogInformation("User with ID '{UserId}' has generated new 2FA recovery codes.", userId);
+            StatusMessage = "You have generated new recovery codes.";
+            return RedirectToPage("./ShowRecoveryCodes");
+        }
+    }
+}

+ 35 - 0
Admin/Areas/Identity/Pages/Account/Manage/Index.cshtml

@@ -0,0 +1,35 @@
+@page
+@model IndexModel
+@{
+    ViewData["Title"] = "기본 정보";
+    ViewData["ActivePage"] = ManageNavPages.Index;
+}
+
+<h3 class="pb-2">@ViewData["Title"]</h3>
+<partial name="_StatusMessage" for="StatusMessage" />
+<div class="row">
+    <div class="col-md-6">
+        <form id="profile-form" method="post" class="mt-3">
+            <div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
+            <div class="form-floating mb-3">
+                <input asp-for="Username" class="form-control" placeholder="Please choose your username." disabled />
+                <label asp-for="Username" class="form-label">이메일</label>
+            </div>
+            <div class="form-floating mb-3">
+                <input asp-for="Input.FullName" class="form-control" placeholder="Please enter your fullname." />
+                <label asp-for="Input.FullName" class="form-label">이름</label>
+                <span asp-validation-for="Input.FullName" class="text-danger"></span>
+            </div>
+            <div class="form-floating mb-3">
+                <input asp-for="Input.PhoneNumber" class="form-control" placeholder="Please enter your phone number."/>
+                <label asp-for="Input.PhoneNumber" class="form-label">연락처</label>
+                <span asp-validation-for="Input.PhoneNumber" class="text-danger"></span>
+            </div>
+            <button id="update-profile-button" type="submit" class="w-100 btn btn-lg btn-primary">저장</button>
+        </form>
+    </div>
+</div>
+
+@section Scripts {
+    <partial name="_ValidationScriptsPartial" />
+}

+ 135 - 0
Admin/Areas/Identity/Pages/Account/Manage/Index.cshtml.cs

@@ -0,0 +1,135 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+#nullable disable
+
+using Infrastructure.Persistence.Identity;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using System;
+using System.ComponentModel.DataAnnotations;
+using System.Text.Encodings.Web;
+using System.Threading.Tasks;
+
+namespace Admin.Areas.Identity.Pages.Account.Manage
+{
+    public class IndexModel : PageModel
+    {
+        private readonly UserManager<ApplicationUser> _userManager;
+        private readonly SignInManager<ApplicationUser> _signInManager;
+
+        public IndexModel(
+            UserManager<ApplicationUser> userManager,
+            SignInManager<ApplicationUser> signInManager)
+        {
+            _userManager = userManager;
+            _signInManager = signInManager;
+        }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public string Username { get; set; }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        [TempData]
+        public string StatusMessage { get; set; }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        [BindProperty]
+        public InputModel Input { get; set; }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public class InputModel
+        {
+            /// <summary>
+            ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+            ///     directly from your code. This API may change or be removed in future releases.
+            /// </summary>
+            [Phone]
+            [Display(Name = "Phone number")]
+            public string PhoneNumber { get; set; }
+
+            [Display(Name = "Full name")]
+            public string FullName { get; set; }
+        }
+
+        private async Task LoadAsync(ApplicationUser user)
+        {
+            var userName = await _userManager.GetUserNameAsync(user);
+            var phoneNumber = await _userManager.GetPhoneNumberAsync(user);
+
+            Username = userName;
+
+            Input = new InputModel
+            {
+                PhoneNumber = phoneNumber,
+                FullName = user.FullName
+            };
+        }
+
+        public async Task<IActionResult> OnGetAsync()
+        {
+            var user = await _userManager.GetUserAsync(User);
+            if (user == null)
+            {
+                return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
+            }
+
+            await LoadAsync(user);
+            return Page();
+        }
+
+        public async Task<IActionResult> OnPostAsync()
+        {
+            var user = await _userManager.GetUserAsync(User);
+            if (user == null)
+            {
+                return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
+            }
+
+            if (!ModelState.IsValid)
+            {
+                await LoadAsync(user);
+                return Page();
+            }
+
+            var phoneNumber = await _userManager.GetPhoneNumberAsync(user);
+            if (Input.PhoneNumber != phoneNumber)
+            {
+                var setPhoneResult = await _userManager.SetPhoneNumberAsync(user, Input.PhoneNumber);
+                if (!setPhoneResult.Succeeded)
+                {
+                    StatusMessage = "Unexpected error when trying to set phone number.";
+                    return RedirectToPage();
+                }
+            }
+
+            if (Input.FullName != user.FullName)
+            {
+                user.SetFullName(Input.FullName);
+
+                var updateResult = await _userManager.UpdateAsync(user);
+                if (!updateResult.Succeeded)
+                {
+                    StatusMessage = "Unexpected error when trying to set full name.";
+                    return RedirectToPage();
+                }
+            }
+
+            await _signInManager.RefreshSignInAsync(user);
+            StatusMessage = "Your profile has been updated";
+            return RedirectToPage();
+        }
+    }
+}

+ 123 - 0
Admin/Areas/Identity/Pages/Account/Manage/ManageNavPages.cs

@@ -0,0 +1,123 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+#nullable disable
+
+using System;
+using Microsoft.AspNetCore.Mvc.Rendering;
+
+namespace  Admin.Areas.Identity.Pages.Account.Manage
+{
+    /// <summary>
+    ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+    ///     directly from your code. This API may change or be removed in future releases.
+    /// </summary>
+    public static class ManageNavPages
+    {
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public static string Index => "Index";
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public static string Email => "Email";
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public static string ChangePassword => "ChangePassword";
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public static string DownloadPersonalData => "DownloadPersonalData";
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public static string DeletePersonalData => "DeletePersonalData";
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public static string ExternalLogins => "ExternalLogins";
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public static string PersonalData => "PersonalData";
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public static string TwoFactorAuthentication => "TwoFactorAuthentication";
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public static string IndexNavClass(ViewContext viewContext) => PageNavClass(viewContext, Index);
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public static string EmailNavClass(ViewContext viewContext) => PageNavClass(viewContext, Email);
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public static string ChangePasswordNavClass(ViewContext viewContext) => PageNavClass(viewContext, ChangePassword);
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public static string DownloadPersonalDataNavClass(ViewContext viewContext) => PageNavClass(viewContext, DownloadPersonalData);
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public static string DeletePersonalDataNavClass(ViewContext viewContext) => PageNavClass(viewContext, DeletePersonalData);
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public static string ExternalLoginsNavClass(ViewContext viewContext) => PageNavClass(viewContext, ExternalLogins);
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public static string PersonalDataNavClass(ViewContext viewContext) => PageNavClass(viewContext, PersonalData);
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public static string TwoFactorAuthenticationNavClass(ViewContext viewContext) => PageNavClass(viewContext, TwoFactorAuthentication);
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public static string PageNavClass(ViewContext viewContext, string page)
+        {
+            var activePage = viewContext.ViewData["ActivePage"] as string
+                ?? System.IO.Path.GetFileNameWithoutExtension(viewContext.ActionDescriptor.DisplayName);
+            return string.Equals(activePage, page, StringComparison.OrdinalIgnoreCase) ? "active" : null;
+        }
+    }
+}

+ 55 - 0
Admin/Areas/Identity/Pages/Account/Manage/PersonalData.cshtml

@@ -0,0 +1,55 @@
+@page
+@* @model PersonalDataModel *@
+@model DeletePersonalDataModel
+@{
+    ViewData["Title"] = "계정 삭제";
+    ViewData["ActivePage"] = ManageNavPages.PersonalData;
+}
+
+<h3 class="pb-2">@ViewData["Title"]</h3>
+
+<div class="alert alert-warning" role="alert">
+    <p class="pb-0 mb-0">
+        <strong>이 데이터를 삭제하면 계정이 영구적으로 삭제되며, 이는 복구할 수 없습니다.</strong>
+    </p>
+</div>
+
+<div>
+    <form id="delete-user" method="post" accept-charset="utf-8" autocomplete="off">
+        <div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
+        @if (Model.RequirePassword)
+        {
+            <div class="form-floating mb-3">
+                <input asp-for="Input.Password" class="form-control" autocomplete="current-password" aria-required="true" placeholder="Please enter your password." />
+                <label asp-for="Input.Password" class="form-label">비밀번호</label>
+                <span asp-validation-for="Input.Password" class="text-danger"></span>
+            </div>
+        }
+        <div class="row">
+            <div class="col col-sm-auto">
+                <button class="btn btn-danger w-100" type="submit">회원탈퇴 처리에 동의합니다.</button>
+            </div>
+        </div>
+    </form>
+</div>
+
+@*
+<div class="row">
+    <div class="col-md-6">
+        <p>Your account contains personal data that you have given us. This page allows you to download or delete that data.</p>
+        <p>
+            <strong>Deleting this data will permanently remove your account, and this cannot be recovered.</strong>
+        </p>
+        <form id="download-data" asp-page="DownloadPersonalData" method="post">
+            <button class="btn btn-primary" type="submit">Download</button>
+        </form>
+        <p>
+            <a id="delete" asp-page="DeletePersonalData" class="btn btn-danger">Delete</a>
+        </p>
+    </div>
+</div>
+*@
+
+@section Scripts {
+    <partial name="_ValidationScriptsPartial" />
+}

+ 37 - 0
Admin/Areas/Identity/Pages/Account/Manage/PersonalData.cshtml.cs

@@ -0,0 +1,37 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+using System;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.Extensions.Logging;
+using Infrastructure.Persistence.Identity;
+
+namespace Admin.Areas.Identity.Pages.Account.Manage
+{
+    public class PersonalDataModel : PageModel
+    {
+        private readonly UserManager<ApplicationUser> _userManager;
+        private readonly ILogger<PersonalDataModel> _logger;
+
+        public PersonalDataModel(
+            UserManager<ApplicationUser> userManager,
+            ILogger<PersonalDataModel> logger)
+        {
+            _userManager = userManager;
+            _logger = logger;
+        }
+
+        public async Task<IActionResult> OnGet()
+        {
+            var user = await _userManager.GetUserAsync(User);
+            if (user == null)
+            {
+                return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
+            }
+
+            return Page();
+        }
+    }
+}

+ 24 - 0
Admin/Areas/Identity/Pages/Account/Manage/ResetAuthenticator.cshtml

@@ -0,0 +1,24 @@
+@page
+@model ResetAuthenticatorModel
+@{
+    ViewData["Title"] = "Reset authenticator key";
+    ViewData["ActivePage"] = ManageNavPages.TwoFactorAuthentication;
+}
+
+<partial name="_StatusMessage" for="StatusMessage" />
+<h3>@ViewData["Title"]</h3>
+<div class="alert alert-warning" role="alert">
+    <p>
+        <span class="glyphicon glyphicon-warning-sign"></span>
+        <strong>If you reset your authenticator key your authenticator app will not work until you reconfigure it.</strong>
+    </p>
+    <p>
+        This process disables 2FA until you verify your authenticator app.
+        If you do not complete your authenticator app configuration you may lose access to your account.
+    </p>
+</div>
+<div>
+    <form id="reset-authenticator-form" method="post">
+        <button id="reset-authenticator-button" class="btn btn-danger" type="submit">Reset authenticator key</button>
+    </form>
+</div>

+ 68 - 0
Admin/Areas/Identity/Pages/Account/Manage/ResetAuthenticator.cshtml.cs

@@ -0,0 +1,68 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+#nullable disable
+
+using Infrastructure.Persistence.Identity;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.Extensions.Logging;
+using System;
+using System.Threading.Tasks;
+
+namespace Admin.Areas.Identity.Pages.Account.Manage
+{
+    public class ResetAuthenticatorModel : PageModel
+    {
+        private readonly UserManager<ApplicationUser> _userManager;
+        private readonly SignInManager<ApplicationUser> _signInManager;
+        private readonly ILogger<ResetAuthenticatorModel> _logger;
+
+        public ResetAuthenticatorModel(
+            UserManager<ApplicationUser> userManager,
+            SignInManager<ApplicationUser> signInManager,
+            ILogger<ResetAuthenticatorModel> logger)
+        {
+            _userManager = userManager;
+            _signInManager = signInManager;
+            _logger = logger;
+        }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        [TempData]
+        public string StatusMessage { get; set; }
+
+        public async Task<IActionResult> OnGet()
+        {
+            var user = await _userManager.GetUserAsync(User);
+            if (user == null)
+            {
+                return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
+            }
+
+            return Page();
+        }
+
+        public async Task<IActionResult> OnPostAsync()
+        {
+            var user = await _userManager.GetUserAsync(User);
+            if (user == null)
+            {
+                return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
+            }
+
+            await _userManager.SetTwoFactorEnabledAsync(user, false);
+            await _userManager.ResetAuthenticatorKeyAsync(user);
+            var userId = await _userManager.GetUserIdAsync(user);
+            _logger.LogInformation("User with ID '{UserId}' has reset their authentication app key.", user.Id);
+
+            await _signInManager.RefreshSignInAsync(user);
+            StatusMessage = "Your authenticator app key has been reset, you will need to configure your authenticator app using the new key.";
+
+            return RedirectToPage("./EnableAuthenticator");
+        }
+    }
+}

+ 35 - 0
Admin/Areas/Identity/Pages/Account/Manage/SetPassword.cshtml

@@ -0,0 +1,35 @@
+@page
+@model SetPasswordModel
+@{
+    ViewData["Title"] = "Set password";
+    ViewData["ActivePage"] = ManageNavPages.ChangePassword;
+}
+
+<h3>Set your password</h3>
+<partial name="_StatusMessage" for="StatusMessage" />
+<p class="text-info">
+    You do not have a local username/password for this site. Add a local
+    account so you can log in without an external login.
+</p>
+<div class="row">
+    <div class="col-md-6">
+        <form id="set-password-form" method="post">
+            <div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
+            <div class="form-floating mb-3">
+                <input asp-for="Input.NewPassword" class="form-control" autocomplete="new-password" placeholder="Please enter your new password."/>
+                <label asp-for="Input.NewPassword" class="form-label"></label>
+                <span asp-validation-for="Input.NewPassword" class="text-danger"></span>
+            </div>
+            <div class="form-floating mb-3">
+                <input asp-for="Input.ConfirmPassword" class="form-control" autocomplete="new-password" placeholder="Please confirm your new password."/>
+                <label asp-for="Input.ConfirmPassword" class="form-label"></label>
+                <span asp-validation-for="Input.ConfirmPassword" class="text-danger"></span>
+            </div>
+            <button type="submit" class="w-100 btn btn-lg btn-primary">Set password</button>
+        </form>
+    </div>
+</div>
+
+@section Scripts {
+    <partial name="_ValidationScriptsPartial" />
+}

+ 115 - 0
Admin/Areas/Identity/Pages/Account/Manage/SetPassword.cshtml.cs

@@ -0,0 +1,115 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+#nullable disable
+
+using Infrastructure.Persistence.Identity;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using System;
+using System.ComponentModel.DataAnnotations;
+using System.Threading.Tasks;
+
+namespace Admin.Areas.Identity.Pages.Account.Manage
+{
+    public class SetPasswordModel : PageModel
+    {
+        private readonly UserManager<ApplicationUser> _userManager;
+        private readonly SignInManager<ApplicationUser> _signInManager;
+
+        public SetPasswordModel(
+            UserManager<ApplicationUser> userManager,
+            SignInManager<ApplicationUser> signInManager)
+        {
+            _userManager = userManager;
+            _signInManager = signInManager;
+        }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        [BindProperty]
+        public InputModel Input { get; set; }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        [TempData]
+        public string StatusMessage { get; set; }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public class InputModel
+        {
+            /// <summary>
+            ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+            ///     directly from your code. This API may change or be removed in future releases.
+            /// </summary>
+            [Required]
+            [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
+            [DataType(DataType.Password)]
+            [Display(Name = "New password")]
+            public string NewPassword { get; set; }
+
+            /// <summary>
+            ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+            ///     directly from your code. This API may change or be removed in future releases.
+            /// </summary>
+            [DataType(DataType.Password)]
+            [Display(Name = "Confirm new password")]
+            [Compare("NewPassword", ErrorMessage = "The new password and confirmation password do not match.")]
+            public string ConfirmPassword { get; set; }
+        }
+
+        public async Task<IActionResult> OnGetAsync()
+        {
+            var user = await _userManager.GetUserAsync(User);
+            if (user == null)
+            {
+                return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
+            }
+
+            var hasPassword = await _userManager.HasPasswordAsync(user);
+
+            if (hasPassword)
+            {
+                return RedirectToPage("./ChangePassword");
+            }
+
+            return Page();
+        }
+
+        public async Task<IActionResult> OnPostAsync()
+        {
+            if (!ModelState.IsValid)
+            {
+                return Page();
+            }
+
+            var user = await _userManager.GetUserAsync(User);
+            if (user == null)
+            {
+                return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
+            }
+
+            var addPasswordResult = await _userManager.AddPasswordAsync(user, Input.NewPassword);
+            if (!addPasswordResult.Succeeded)
+            {
+                foreach (var error in addPasswordResult.Errors)
+                {
+                    ModelState.AddModelError(string.Empty, error.Description);
+                }
+                return Page();
+            }
+
+            await _signInManager.RefreshSignInAsync(user);
+            StatusMessage = "Your password has been set.";
+
+            return RedirectToPage();
+        }
+    }
+}

+ 25 - 0
Admin/Areas/Identity/Pages/Account/Manage/ShowRecoveryCodes.cshtml

@@ -0,0 +1,25 @@
+@page
+@model ShowRecoveryCodesModel
+@{
+    ViewData["Title"] = "Recovery codes";
+    ViewData["ActivePage"] = "TwoFactorAuthentication";
+}
+
+<partial name="_StatusMessage" for="StatusMessage" />
+<h3>@ViewData["Title"]</h3>
+<div class="alert alert-warning" role="alert">
+    <p>
+        <strong>Put these codes in a safe place.</strong>
+    </p>
+    <p>
+        If you lose your device and don't have the recovery codes you will lose access to your account.
+    </p>
+</div>
+<div class="row">
+    <div class="col-md-12">
+        @for (var row = 0; row < Model.RecoveryCodes.Length; row += 2)
+        {
+            <code class="recovery-code">@Model.RecoveryCodes[row]</code><text>&nbsp;</text><code class="recovery-code">@Model.RecoveryCodes[row + 1]</code><br />
+        }
+    </div>
+</div>

+ 46 - 0
Admin/Areas/Identity/Pages/Account/Manage/ShowRecoveryCodes.cshtml.cs

@@ -0,0 +1,46 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+#nullable disable
+
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.Extensions.Logging;
+
+namespace Admin.Areas.Identity.Pages.Account.Manage
+{
+    /// <summary>
+    ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+    ///     directly from your code. This API may change or be removed in future releases.
+    /// </summary>
+    public class ShowRecoveryCodesModel : PageModel
+    {
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        [TempData]
+        public string[] RecoveryCodes { get; set; }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        [TempData]
+        public string StatusMessage { get; set; }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public IActionResult OnGet()
+        {
+            if (RecoveryCodes == null || RecoveryCodes.Length == 0)
+            {
+                return RedirectToPage("./TwoFactorAuthentication");
+            }
+
+            return Page();
+        }
+    }
+}

+ 71 - 0
Admin/Areas/Identity/Pages/Account/Manage/TwoFactorAuthentication.cshtml

@@ -0,0 +1,71 @@
+@page
+@using Microsoft.AspNetCore.Http.Features
+@model TwoFactorAuthenticationModel
+@{
+    ViewData["Title"] = "Two-factor authentication (2FA)";
+    ViewData["ActivePage"] = ManageNavPages.TwoFactorAuthentication;
+}
+
+<partial name="_StatusMessage" for="StatusMessage" />
+<h3>@ViewData["Title"]</h3>
+@{
+    var consentFeature = HttpContext.Features.Get<ITrackingConsentFeature>();
+    @if (consentFeature?.CanTrack ?? true)
+    {
+        @if (Model.Is2faEnabled)
+        {
+            if (Model.RecoveryCodesLeft == 0)
+            {
+                <div class="alert alert-danger">
+                    <strong>You have no recovery codes left.</strong>
+                    <p>You must <a asp-page="./GenerateRecoveryCodes">generate a new set of recovery codes</a> before you can log in with a recovery code.</p>
+                </div>
+            }
+            else if (Model.RecoveryCodesLeft == 1)
+            {
+                <div class="alert alert-danger">
+                    <strong>You have 1 recovery code left.</strong>
+                    <p>You can <a asp-page="./GenerateRecoveryCodes">generate a new set of recovery codes</a>.</p>
+                </div>
+            }
+            else if (Model.RecoveryCodesLeft <= 3)
+            {
+                <div class="alert alert-warning">
+                    <strong>You have @Model.RecoveryCodesLeft recovery codes left.</strong>
+                    <p>You should <a asp-page="./GenerateRecoveryCodes">generate a new set of recovery codes</a>.</p>
+                </div>
+            }
+
+            if (Model.IsMachineRemembered)
+            {
+                <form method="post" style="display: inline-block">
+                    <button type="submit" class="btn btn-primary">Forget this browser</button>
+                </form>
+            }
+            <a asp-page="./Disable2fa" class="btn btn-primary">Disable 2FA</a>
+            <a asp-page="./GenerateRecoveryCodes" class="btn btn-primary">Reset recovery codes</a>
+        }
+
+        <h4>Authenticator app</h4>
+        @if (!Model.HasAuthenticator)
+        {
+            <a id="enable-authenticator" asp-page="./EnableAuthenticator" class="btn btn-primary">Add authenticator app</a>
+        }
+        else
+        {
+            <a id="enable-authenticator" asp-page="./EnableAuthenticator" class="btn btn-primary">Set up authenticator app</a>
+            <a id="reset-authenticator" asp-page="./ResetAuthenticator" class="btn btn-primary">Reset authenticator app</a>
+        }
+    }
+    else
+    {
+        <div class="alert alert-danger">
+            <strong>Privacy and cookie policy have not been accepted.</strong>
+            <p>You must accept the policy before you can enable two factor authentication.</p>
+        </div>
+    }
+}
+
+@section Scripts {
+    <partial name="_ValidationScriptsPartial" />
+}

+ 90 - 0
Admin/Areas/Identity/Pages/Account/Manage/TwoFactorAuthentication.cshtml.cs

@@ -0,0 +1,90 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+#nullable disable
+
+using System;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.Extensions.Logging;
+using Infrastructure.Persistence.Identity;
+
+namespace Admin.Areas.Identity.Pages.Account.Manage
+{
+    public class TwoFactorAuthenticationModel : PageModel
+    {
+        private readonly UserManager<ApplicationUser> _userManager;
+        private readonly SignInManager<ApplicationUser> _signInManager;
+        private readonly ILogger<TwoFactorAuthenticationModel> _logger;
+
+        public TwoFactorAuthenticationModel(
+            UserManager<ApplicationUser> userManager, SignInManager<ApplicationUser> signInManager, ILogger<TwoFactorAuthenticationModel> logger)
+        {
+            _userManager = userManager;
+            _signInManager = signInManager;
+            _logger = logger;
+        }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public bool HasAuthenticator { get; set; }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public int RecoveryCodesLeft { get; set; }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        [BindProperty]
+        public bool Is2faEnabled { get; set; }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public bool IsMachineRemembered { get; set; }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        [TempData]
+        public string StatusMessage { get; set; }
+
+        public async Task<IActionResult> OnGetAsync()
+        {
+            var user = await _userManager.GetUserAsync(User);
+            if (user == null)
+            {
+                return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
+            }
+
+            HasAuthenticator = await _userManager.GetAuthenticatorKeyAsync(user) != null;
+            Is2faEnabled = await _userManager.GetTwoFactorEnabledAsync(user);
+            IsMachineRemembered = await _signInManager.IsTwoFactorClientRememberedAsync(user);
+            RecoveryCodesLeft = await _userManager.CountRecoveryCodesAsync(user);
+
+            return Page();
+        }
+
+        public async Task<IActionResult> OnPostAsync()
+        {
+            var user = await _userManager.GetUserAsync(User);
+            if (user == null)
+            {
+                return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
+            }
+
+            await _signInManager.ForgetTwoFactorClientAsync();
+            StatusMessage = "The current browser has been forgotten. When you login again from this browser you will be prompted for your 2fa code.";
+            return RedirectToPage();
+        }
+    }
+}

+ 27 - 0
Admin/Areas/Identity/Pages/Account/Manage/_Layout.cshtml

@@ -0,0 +1,27 @@
+@{
+    if (ViewData.TryGetValue("ParentLayout", out var parentLayout) && parentLayout !=  null)
+    {
+        Layout = parentLayout.ToString();
+    }
+    else
+    {
+        Layout = "/Areas/Identity/Pages/_Layout.cshtml";
+    }
+}
+
+<div class="container">
+    <h5>내 정보</h5>
+    <hr />
+    <div class="row">
+        <div class="col-md-3">
+            <partial name="_ManageNav" />
+        </div>
+        <div class="col-md-9">
+            @RenderBody()
+        </div>
+    </div>
+</div>
+
+@section Scripts {
+    @RenderSection("Scripts", required: false)
+}

+ 16 - 0
Admin/Areas/Identity/Pages/Account/Manage/_ManageNav.cshtml

@@ -0,0 +1,16 @@
+@using Infrastructure.Persistence.Identity
+@inject SignInManager<ApplicationUser> SignInManager
+@{
+    var hasExternalLogins = (await SignInManager.GetExternalAuthenticationSchemesAsync()).Any();
+}
+<ul class="nav nav-pills flex-column mb-4 mb-md-0">
+    <li class="nav-item"><a class="nav-link @ManageNavPages.IndexNavClass(ViewContext)" id="profile" asp-page="./Index">기본 정보</a></li>
+    <li class="nav-item"><a class="nav-link @ManageNavPages.EmailNavClass(ViewContext)" id="email" asp-page="./Email">이메일 변경</a></li>
+    <li class="nav-item"><a class="nav-link @ManageNavPages.ChangePasswordNavClass(ViewContext)" id="change-password" asp-page="./ChangePassword">비밀번호 변경</a></li>
+    @if (hasExternalLogins)
+    {
+        <li id="external-logins" class="nav-item"><a id="external-login" class="nav-link @ManageNavPages.ExternalLoginsNavClass(ViewContext)" asp-page="./ExternalLogins">External logins</a></li>
+    }
+    @* <li class="nav-item"><a class="nav-link @ManageNavPages.TwoFactorAuthenticationNavClass(ViewContext)" id="two-factor" asp-page="./TwoFactorAuthentication">Two-factor authentication</a></li> *@
+    <li class="nav-item"><a class="nav-link @ManageNavPages.PersonalDataNavClass(ViewContext)" id="personal-data" asp-page="./PersonalData">계정 삭제</a></li>
+</ul>

+ 10 - 0
Admin/Areas/Identity/Pages/Account/Manage/_StatusMessage.cshtml

@@ -0,0 +1,10 @@
+@model string
+
+@if (!String.IsNullOrEmpty(Model))
+{
+    var statusMessageClass = Model.StartsWith("Error") ? "danger" : "success";
+    <div class="alert alert-@statusMessageClass alert-dismissible m-0" role="alert">
+        <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
+        @Model
+    </div>
+}

+ 1 - 0
Admin/Areas/Identity/Pages/Account/Manage/_ViewImports.cshtml

@@ -0,0 +1 @@
+@using Admin.Areas.Identity.Pages.Account.Manage

+ 82 - 0
Admin/Areas/Identity/Pages/Account/Register.cshtml

@@ -0,0 +1,82 @@
+@page
+@model RegisterModel
+@{
+    ViewData["Title"] = "회원가입";
+}
+<div id="registForm" class="row row-cols-1 justify-content-center align-items-center min-vh-100">
+    <div class="col col-12 col-sm-7 col-md-5 col-lg-5 col-xl-3">
+        <section>
+            <h4>@ViewData["Title"]</h4>
+
+            <form id="registerForm" asp-route-returnUrl="@Model.ReturnUrl" method="post">
+                <small>가입 후 이메일 인증이 필요하며 관리자의 <br />최종 승인 후 접속이 가능합니다.</small>
+                <hr />
+                <div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
+                <div class="form-floating mb-3">
+                    <input asp-for="Input.Email" class="form-control" autocomplete="username" aria-required="true" placeholder="name@example.com" />
+                    <label asp-for="Input.Email">이메일</label>
+                    <span asp-validation-for="Input.Email" class="text-danger"></span>
+                </div>
+                <div class="form-floating mb-3">
+                    <input asp-for="Input.Password" class="form-control" autocomplete="new-password" aria-required="true" placeholder="password" />
+                    <label asp-for="Input.Password">비밀번호</label>
+                    <span asp-validation-for="Input.Password" class="text-danger"></span>
+                </div>
+                <div class="form-floating mb-3">
+                    <input asp-for="Input.ConfirmPassword" class="form-control" autocomplete="new-password" aria-required="true" placeholder="password" />
+                    <label asp-for="Input.ConfirmPassword">비밀번호 재설정</label>
+                    <span asp-validation-for="Input.ConfirmPassword" class="text-danger"></span>
+                </div>
+                <button id="registerSubmit" type="submit" class="w-100 btn btn-lg btn-primary">회원가입</button>
+                <p class="pt-3 text-center">
+                    <a asp-page="./Login" asp-route-returnUrl="@Model.ReturnUrl">< 취소하기</a>
+                </p>
+            </form>
+        </section>
+    </div>
+    <div class="col">
+        <div class="text-center ps-3 pe-3">
+            <hr />
+            <small>ⓒ PLAYR. All Rights Reserved</small>
+        </div>
+
+        @*
+        <section>
+            <h3>Use another service to register.</h3>
+            <hr />
+            @{
+                if ((Model.ExternalLogins?.Count ?? 0) == 0)
+                {
+                    <div>
+                        <p>
+                            There are no external authentication services configured. See this <a href="https://go.microsoft.com/fwlink/?LinkID=532715">article
+                            about setting up this ASP.NET application to support logging in via external services</a>.
+                        </p>
+                    </div>
+                }
+                else
+                {
+                    <form id="external-account" asp-page="./ExternalLogin" asp-route-returnUrl="@Model.ReturnUrl" method="post" class="form-horizontal">
+                        <div>
+                            <p>
+                                @foreach (var provider in Model.ExternalLogins!)
+                                {
+                                    <button type="submit" class="btn btn-primary" name="provider" value="@provider.Name" title="Log in using your @provider.DisplayName account">@provider.DisplayName</button>
+                                }
+                            </p>
+                        </div>
+                    </form>
+                }
+            }
+        </section>
+        *@
+    </div>
+</div>
+
+@section Scripts {
+    <partial name="_ValidationScriptsPartial" />
+}
+
+@section Styles {
+    <link rel="stylesheet" href="~/css/account.css" asp-append-version="true" />
+}

+ 181 - 0
Admin/Areas/Identity/Pages/Account/Register.cshtml.cs

@@ -0,0 +1,181 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+#nullable disable
+
+using Infrastructure.Persistence.Identity;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Identity.UI.Services;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.WebUtilities;
+using Microsoft.Extensions.Logging;
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.Linq;
+using System.Text;
+using System.Text.Encodings.Web;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Admin.Areas.Identity.Pages.Account
+{
+    public class RegisterModel : PageModel
+    {
+        private readonly SignInManager<ApplicationUser> _signInManager;
+        private readonly UserManager<ApplicationUser> _userManager;
+        private readonly IUserStore<ApplicationUser> _userStore;
+        private readonly IUserEmailStore<ApplicationUser> _emailStore;
+        private readonly ILogger<RegisterModel> _logger;
+        private readonly IEmailSender _emailSender;
+
+        public RegisterModel(
+            UserManager<ApplicationUser> userManager,
+            IUserStore<ApplicationUser> userStore,
+            SignInManager<ApplicationUser> signInManager,
+            ILogger<RegisterModel> logger,
+            IEmailSender emailSender)
+        {
+            _userManager = userManager;
+            _userStore = userStore;
+            _emailStore = GetEmailStore();
+            _signInManager = signInManager;
+            _logger = logger;
+            _emailSender = emailSender;
+        }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        [BindProperty]
+        public InputModel Input { get; set; }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public string ReturnUrl { get; set; }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public IList<AuthenticationScheme> ExternalLogins { get; set; }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public class InputModel
+        {
+            /// <summary>
+            ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+            ///     directly from your code. This API may change or be removed in future releases.
+            /// </summary>
+            [Required]
+            [EmailAddress]
+            [Display(Name = "Email")]
+            public string Email { get; set; }
+
+            /// <summary>
+            ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+            ///     directly from your code. This API may change or be removed in future releases.
+            /// </summary>
+            [Required]
+            [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
+            [DataType(DataType.Password)]
+            [Display(Name = "Password")]
+            public string Password { get; set; }
+
+            /// <summary>
+            ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+            ///     directly from your code. This API may change or be removed in future releases.
+            /// </summary>
+            [DataType(DataType.Password)]
+            [Display(Name = "Confirm password")]
+            [Compare("Password", ErrorMessage = "The password and confirmation password do not match.")]
+            public string ConfirmPassword { get; set; }
+        }
+
+
+        public async Task OnGetAsync(string returnUrl = null)
+        {
+            ReturnUrl = returnUrl;
+            ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToList();
+        }
+
+        public async Task<IActionResult> OnPostAsync(string returnUrl = null)
+        {
+            returnUrl ??= Url.Content("~/");
+            ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToList();
+            if (ModelState.IsValid)
+            {
+                var user = CreateUser();
+
+                await _userStore.SetUserNameAsync(user, Input.Email, CancellationToken.None);
+                await _emailStore.SetEmailAsync(user, Input.Email, CancellationToken.None);
+                var result = await _userManager.CreateAsync(user, Input.Password);
+
+                if (result.Succeeded)
+                {
+                    _logger.LogInformation("User created a new account with password.");
+
+                    var userId = await _userManager.GetUserIdAsync(user);
+                    var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
+                    code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
+                    var callbackUrl = Url.Page(
+                        "/Account/ConfirmEmail",
+                        pageHandler: null,
+                        values: new { area = "Identity", userId = userId, code = code, returnUrl = returnUrl },
+                        protocol: Request.Scheme);
+
+                    await _emailSender.SendEmailAsync(Input.Email, "Confirm your email",
+                        $"Please confirm your account by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>.");
+
+                    if (_userManager.Options.SignIn.RequireConfirmedAccount)
+                    {
+                        return RedirectToPage("RegisterConfirmation", new { email = Input.Email, returnUrl = returnUrl });
+                    }
+                    else
+                    {
+                        await _signInManager.SignInAsync(user, isPersistent: false);
+                        return LocalRedirect(returnUrl);
+                    }
+                }
+                foreach (var error in result.Errors)
+                {
+                    ModelState.AddModelError(string.Empty, error.Description);
+                }
+            }
+
+            // If we got this far, something failed, redisplay form
+            return Page();
+        }
+
+        private ApplicationUser CreateUser()
+        {
+            try
+            {
+                return Activator.CreateInstance<ApplicationUser>();
+            }
+            catch
+            {
+                throw new InvalidOperationException($"Can't create an instance of '{nameof(ApplicationUser)}'. " +
+                    $"Ensure that '{nameof(ApplicationUser)}' is not an abstract class and has a parameterless constructor, or alternatively " +
+                    $"override the register page in /Areas/Identity/Pages/Account/Register.cshtml");
+            }
+        }
+
+        private IUserEmailStore<ApplicationUser> GetEmailStore()
+        {
+            if (!_userManager.SupportsUserEmail)
+            {
+                throw new NotSupportedException("The default UI requires a user store with email support.");
+            }
+            return (IUserEmailStore<ApplicationUser>)_userStore;
+        }
+    }
+}

+ 31 - 0
Admin/Areas/Identity/Pages/Account/RegisterConfirmation.cshtml

@@ -0,0 +1,31 @@
+@page
+@model RegisterConfirmationModel
+@{
+    ViewData["Title"] = "Register confirmation";
+}
+@*
+
+<h1>@ViewData["Title"]</h1>
+@{
+    if (@Model.DisplayConfirmAccountLink)
+    {
+<p>
+    This app does not currently have a real email sender registered, see <a href="https://aka.ms/aspaccountconf">these docs</a> for how to configure a real email sender.
+    Normally this would be emailed: <a id="confirm-link" href="@Model.EmailConfirmationUrl">Click here to confirm your account</a>
+</p>
+    }
+    else
+    {
+<p>
+        Please check your email to confirm your account.
+</p>
+    }
+}
+*@
+
+<script>
+    setTimeout(() => {
+        alert("이메일 확인 메일을 전송했습니다. 인증 확인 후 접속이 가능합니다.");
+        window.location.replace("/Identity/Account/Login");
+    }, 0);
+</script>

+ 80 - 0
Admin/Areas/Identity/Pages/Account/RegisterConfirmation.cshtml.cs

@@ -0,0 +1,80 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+#nullable disable
+
+using System;
+using System.Text;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Identity.UI.Services;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.WebUtilities;
+using Infrastructure.Persistence.Identity;
+
+namespace Admin.Areas.Identity.Pages.Account
+{
+    [AllowAnonymous]
+    public class RegisterConfirmationModel : PageModel
+    {
+        private readonly UserManager<ApplicationUser> _userManager;
+        private readonly IEmailSender _sender;
+
+        public RegisterConfirmationModel(UserManager<ApplicationUser> userManager, IEmailSender sender)
+        {
+            _userManager = userManager;
+            _sender = sender;
+        }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public string Email { get; set; }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public bool DisplayConfirmAccountLink { get; set; }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public string EmailConfirmationUrl { get; set; }
+
+        public async Task<IActionResult> OnGetAsync(string email, string returnUrl = null)
+        {
+            if (email == null)
+            {
+                return RedirectToPage("/Index");
+            }
+            returnUrl = returnUrl ?? Url.Content("~/");
+
+            var user = await _userManager.FindByEmailAsync(email);
+            if (user == null)
+            {
+                return NotFound($"Unable to load user with email '{email}'.");
+            }
+
+            Email = email;
+            // Once you add a real email sender, you should remove this code that lets you confirm the account
+            DisplayConfirmAccountLink = true;
+            if (DisplayConfirmAccountLink)
+            {
+                var userId = await _userManager.GetUserIdAsync(user);
+                var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
+                code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
+                EmailConfirmationUrl = Url.Page(
+                    "/Account/ConfirmEmail",
+                    pageHandler: null,
+                    values: new { area = "Identity", userId = userId, code = code, returnUrl = returnUrl },
+                    protocol: Request.Scheme);
+            }
+
+            return Page();
+        }
+    }
+}

+ 42 - 0
Admin/Areas/Identity/Pages/Account/ResendEmailConfirmation.cshtml

@@ -0,0 +1,42 @@
+@page
+@model ResendEmailConfirmationModel
+@{
+    ViewData["Title"] = "이메일 인증 재전송";
+}
+
+<div id="resendEmailConfirmForm" class="row row-cols-1 justify-content-center align-items-center min-vh-100">
+    <div class="col col-12 col-sm-7 col-md-5 col-lg-5 col-xl-3">
+        <section>
+            <form method="post" accept-charset="utf-8" autocomplete="off">
+                <h4>@ViewData["Title"]</h4>
+                <small>가입 시 이메일을 입력하세요.</small>
+                <hr/>
+
+                <div asp-validation-summary="All" class="text-danger" role="alert"></div>
+                <div class="form-floating mb-3">
+                    <input asp-for="Input.Email" class="form-control" aria-required="true" placeholder="name@example.com" />
+                    <label asp-for="Input.Email" class="form-label">이메일</label>
+                    <span asp-validation-for="Input.Email" class="text-danger"></span>
+                </div>
+                <button type="submit" class="w-100 btn btn-lg btn-primary">재전송</button>
+                <p class="pt-3 text-center">
+                    <a asp-page="./Login">< 취소하기</a>
+                </p>
+            </form>
+        </section>
+    </div>
+    <div class="col">
+        <div class="text-center ps-3 pe-3">
+            <hr />
+            <small>ⓒ PLAYR. All Rights Reserved</small>
+        </div>
+    </div>
+</div>
+
+@section Scripts {
+    <partial name="_ValidationScriptsPartial" />
+}
+
+@section Styles {
+    <link rel="stylesheet" href="~/css/account.css" asp-append-version="true" />
+}

+ 89 - 0
Admin/Areas/Identity/Pages/Account/ResendEmailConfirmation.cshtml.cs

@@ -0,0 +1,89 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+#nullable disable
+
+using Infrastructure.Persistence.Identity;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Identity.UI.Services;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.WebUtilities;
+using System;
+using System.ComponentModel.DataAnnotations;
+using System.Text;
+using System.Text.Encodings.Web;
+using System.Threading.Tasks;
+
+namespace Admin.Areas.Identity.Pages.Account
+{
+    [AllowAnonymous]
+    public class ResendEmailConfirmationModel : PageModel
+    {
+        private readonly UserManager<ApplicationUser> _userManager;
+        private readonly IEmailSender _emailSender;
+
+        public ResendEmailConfirmationModel(UserManager<ApplicationUser> userManager, IEmailSender emailSender)
+        {
+            _userManager = userManager;
+            _emailSender = emailSender;
+        }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        [BindProperty]
+        public InputModel Input { get; set; }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public class InputModel
+        {
+            /// <summary>
+            ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+            ///     directly from your code. This API may change or be removed in future releases.
+            /// </summary>
+            [Required]
+            [EmailAddress]
+            public string Email { get; set; }
+        }
+
+        public void OnGet()
+        {
+        }
+
+        public async Task<IActionResult> OnPostAsync()
+        {
+            if (!ModelState.IsValid)
+            {
+                return Page();
+            }
+
+            var user = await _userManager.FindByEmailAsync(Input.Email);
+            if (user == null)
+            {
+                ModelState.AddModelError(string.Empty, "Verification email sent. Please check your email.");
+                return Page();
+            }
+
+            var userId = await _userManager.GetUserIdAsync(user);
+            var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
+            code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
+            var callbackUrl = Url.Page(
+                "/Account/ConfirmEmail",
+                pageHandler: null,
+                values: new { userId = userId, code = code },
+                protocol: Request.Scheme);
+            await _emailSender.SendEmailAsync(
+                Input.Email,
+                "Confirm your email",
+                $"Please confirm your account by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>.");
+
+            ModelState.AddModelError(string.Empty, "Verification email sent. Please check your email.");
+            return Page();
+        }
+    }
+}

+ 47 - 0
Admin/Areas/Identity/Pages/Account/ResetPassword.cshtml

@@ -0,0 +1,47 @@
+@page
+@model ResetPasswordModel
+@{
+    ViewData["Title"] = "비밀번호 변경";
+}
+
+<div id="resetPasswordForm" class="row row-cols-1 justify-content-center align-items-center min-vh-100">
+    <div class="col col-12 col-sm-7 col-md-5 col-lg-5 col-xl-3">
+        <section>
+            <form method="post" accept-charset="utf-8" autocomplete="off">
+                <h4>@ViewData["Title"]</h4>
+                <small>비밀번호를 재설정 합니다.</small>
+                <hr />
+
+                <div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
+                <input asp-for="Input.Code" type="hidden" />
+                <div class="form-floating mb-3">
+                    <input asp-for="Input.Email" class="form-control" autocomplete="username" aria-required="true" placeholder="name@example.com" />
+                    <label asp-for="Input.Email" class="form-label">이메일</label>
+                    <span asp-validation-for="Input.Email" class="text-danger"></span>
+                </div>
+                <div class="form-floating mb-3">
+                    <input asp-for="Input.Password" class="form-control" autocomplete="new-password" aria-required="true" placeholder="Please enter your password." />
+                    <label asp-for="Input.Password" class="form-label">비밀번호</label>
+                    <span asp-validation-for="Input.Password" class="text-danger"></span>
+                </div>
+                <div class="form-floating mb-3">
+                    <input asp-for="Input.ConfirmPassword" class="form-control" autocomplete="new-password" aria-required="true" placeholder="Please confirm your password." />
+                    <label asp-for="Input.ConfirmPassword" class="form-label">비밀번호 재입력</label>
+                    <span asp-validation-for="Input.ConfirmPassword" class="text-danger"></span>
+                </div>
+                <button type="submit" class="w-100 btn btn-lg btn-primary">변경하기</button>
+                <p class="pt-3 text-center">
+                    <a asp-page="./Login">< 취소하기</a>
+                </p>
+            </form>
+        </section>
+    </div>
+</div>
+
+@section Scripts {
+    <partial name="_ValidationScriptsPartial" />
+}
+
+@section Styles {
+    <link rel="stylesheet" href="~/css/account.css" asp-append-version="true" />
+}

+ 118 - 0
Admin/Areas/Identity/Pages/Account/ResetPassword.cshtml.cs

@@ -0,0 +1,118 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+#nullable disable
+
+using Infrastructure.Persistence.Identity;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.WebUtilities;
+using System;
+using System.ComponentModel.DataAnnotations;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace Admin.Areas.Identity.Pages.Account
+{
+    public class ResetPasswordModel : PageModel
+    {
+        private readonly UserManager<ApplicationUser> _userManager;
+
+        public ResetPasswordModel(UserManager<ApplicationUser> userManager)
+        {
+            _userManager = userManager;
+        }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        [BindProperty]
+        public InputModel Input { get; set; }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public class InputModel
+        {
+            /// <summary>
+            ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+            ///     directly from your code. This API may change or be removed in future releases.
+            /// </summary>
+            [Required]
+            [EmailAddress]
+            public string Email { get; set; }
+
+            /// <summary>
+            ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+            ///     directly from your code. This API may change or be removed in future releases.
+            /// </summary>
+            [Required]
+            [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
+            [DataType(DataType.Password)]
+            public string Password { get; set; }
+
+            /// <summary>
+            ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+            ///     directly from your code. This API may change or be removed in future releases.
+            /// </summary>
+            [DataType(DataType.Password)]
+            [Display(Name = "Confirm password")]
+            [Compare("Password", ErrorMessage = "The password and confirmation password do not match.")]
+            public string ConfirmPassword { get; set; }
+
+            /// <summary>
+            ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+            ///     directly from your code. This API may change or be removed in future releases.
+            /// </summary>
+            [Required]
+            public string Code { get; set; }
+
+        }
+
+        public IActionResult OnGet(string code = null)
+        {
+            if (code == null)
+            {
+                return BadRequest("A code must be supplied for password reset.");
+            }
+            else
+            {
+                Input = new InputModel
+                {
+                    Code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code))
+                };
+                return Page();
+            }
+        }
+
+        public async Task<IActionResult> OnPostAsync()
+        {
+            if (!ModelState.IsValid)
+            {
+                return Page();
+            }
+
+            var user = await _userManager.FindByEmailAsync(Input.Email);
+            if (user == null)
+            {
+                // Don't reveal that the user does not exist
+                return RedirectToPage("./ResetPasswordConfirmation");
+            }
+
+            var result = await _userManager.ResetPasswordAsync(user, Input.Code, Input.Password);
+            if (result.Succeeded)
+            {
+                return RedirectToPage("./ResetPasswordConfirmation");
+            }
+
+            foreach (var error in result.Errors)
+            {
+                ModelState.AddModelError(string.Empty, error.Description);
+            }
+            return Page();
+        }
+    }
+}

+ 19 - 0
Admin/Areas/Identity/Pages/Account/ResetPasswordConfirmation.cshtml

@@ -0,0 +1,19 @@
+@page
+@model ResetPasswordConfirmationModel
+@{
+    ViewData["Title"] = "Reset password confirmation";
+}
+
+@*
+<h1>@ViewData["Title"]</h1>
+<p>
+    Your password has been reset. Please <a asp-page="./Login">click here to log in</a>.
+</p>
+*@
+
+<script>
+    setTimeout(() => {
+        alert("비밀번호가 변경되었습니다. 다시 로그인 해주세요.");
+        window.location.replace("/Identity/Account/Login");
+    }, 0);
+</script>

+ 25 - 0
Admin/Areas/Identity/Pages/Account/ResetPasswordConfirmation.cshtml.cs

@@ -0,0 +1,25 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+#nullable disable
+
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace Admin.Areas.Identity.Pages.Account
+{
+    /// <summary>
+    ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+    ///     directly from your code. This API may change or be removed in future releases.
+    /// </summary>
+    [AllowAnonymous]
+    public class ResetPasswordConfirmationModel : PageModel
+    {
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public void OnGet()
+        {
+        }
+    }
+}

+ 10 - 0
Admin/Areas/Identity/Pages/Account/_StatusMessage.cshtml

@@ -0,0 +1,10 @@
+@model string
+
+@if (!String.IsNullOrEmpty(Model))
+{
+    var statusMessageClass = Model.StartsWith("Error") ? "danger" : "success";
+    <div class="alert alert-@statusMessageClass alert-dismissible" role="alert">
+        <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
+        @Model
+    </div>
+}

+ 1 - 0
Admin/Areas/Identity/Pages/Account/_ViewImports.cshtml

@@ -0,0 +1 @@
+@using Admin.Areas.Identity.Pages.Account

+ 23 - 0
Admin/Areas/Identity/Pages/Error.cshtml

@@ -0,0 +1,23 @@
+@page
+@model ErrorModel
+@{
+    ViewData["Title"] = "Error";
+}
+
+<h1 class="text-danger">Error.</h1>
+<h2 class="text-danger">An error occurred while processing your request.</h2>
+
+@if (Model.ShowRequestId)
+{
+    <p>
+        <strong>Request ID:</strong> <code>@Model.RequestId</code>
+    </p>
+}
+
+<h3>Development Mode</h3>
+<p>
+    Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
+</p>
+<p>
+    <strong>Development environment should not be enabled in deployed applications</strong>, as it can result in sensitive information from exceptions being displayed to end users. For local debugging, development environment can be enabled by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>, and restarting the application.
+</p>

+ 41 - 0
Admin/Areas/Identity/Pages/Error.cshtml.cs

@@ -0,0 +1,41 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+#nullable disable
+
+using System.Diagnostics;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace Admin.Areas.Identity.Pages
+{
+    /// <summary>
+    ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+    ///     directly from your code. This API may change or be removed in future releases.
+    /// </summary>
+    [AllowAnonymous]
+    [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
+    public class ErrorModel : PageModel
+    {
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public string RequestId { get; set; }
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
+
+        /// <summary>
+        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+        ///     directly from your code. This API may change or be removed in future releases.
+        /// </summary>
+        public void OnGet()
+        {
+            RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier;
+        }
+    }
+}

+ 2 - 0
Admin/Areas/Identity/Pages/_ValidationScriptsPartial.cshtml

@@ -0,0 +1,2 @@
+<script src="~/lib/jquery-validation/dist/jquery.validate.min.js"></script>
+<script src="~/lib/jquery-validation-unobtrusive/dist/jquery.validate.unobtrusive.min.js"></script>

+ 4 - 0
Admin/Areas/Identity/Pages/_ViewImports.cshtml

@@ -0,0 +1,4 @@
+@using Microsoft.AspNetCore.Identity
+@using Admin.Areas.Identity
+@using Admin.Areas.Identity.Pages
+@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

+ 12 - 0
Admin/Areas/Identity/Pages/_ViewStart.cshtml

@@ -0,0 +1,12 @@
+@using Microsoft.AspNetCore.Identity
+
+@{
+    if (User?.Identity?.IsAuthenticated == true)
+    {
+        Layout = "~/Pages/Shared/_Layout.cshtml";
+    }
+    else
+    {
+        Layout = "~/Pages/Shared/_Sub.cshtml";
+    }
+}

+ 12 - 0
Admin/Extensions/NavActiveExtension.cs

@@ -0,0 +1,12 @@
+using Microsoft.AspNetCore.Mvc.Rendering;
+
+namespace Admin.Extensions
+{
+    public static class NavActiveExtensions
+    {
+        public static string IsActive(this IHtmlHelper html, string page)
+        {
+            return string.Equals(html.ViewContext.RouteData.Values["page"]?.ToString(), page, StringComparison.OrdinalIgnoreCase) ? "active" : "";
+        }
+    }
+}

+ 126 - 0
Admin/Extensions/ServiceCollectionExtensions.cs

@@ -0,0 +1,126 @@
+using SharedKernel;
+using Infrastructure.Persistence;
+using Infrastructure.Persistence.Identity;
+using Infrastructure.Extensions;
+using Microsoft.AspNetCore.Authentication.Cookies;
+using Microsoft.AspNetCore.HttpOverrides;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Extensions.Caching.Distributed;
+using System.Net;
+using IPNetwork = Microsoft.AspNetCore.HttpOverrides.IPNetwork;
+
+namespace Admin.Extensions
+{
+    public static class ServiceCollectionExtensions
+    {
+        public static void AddAdminForwardedHeaders(this IServiceCollection services, AppSettings settings)
+        {
+            services.Configure<ForwardedHeadersOptions>(options =>
+            {
+                options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost;
+                options.ForwardLimit = settings.ForwardedHeaders.ForwardLimit <= 0 ? 1 : settings.ForwardedHeaders.ForwardLimit;
+
+                // 설정에서 KnownProxies / KnownNetworks 읽기
+                var knownProxies = settings.ForwardedHeaders.KnownProxies;
+                if (knownProxies != null)
+                {
+                    foreach (var proxy in knownProxies)
+                    {
+                        if (IPAddress.TryParse(proxy, out var ip))
+                        {
+                            options.KnownProxies.Add(ip);
+                        }
+                    }
+                }
+
+                var knownNetworks = settings.ForwardedHeaders.KnownNetworks;
+                if (knownNetworks != null)
+                {
+                    foreach (var net in knownNetworks)
+                    {
+                        var parts = net.Split('/');
+                        if (parts.Length == 2 && IPAddress.TryParse(parts[0], out var networkIp) && int.TryParse(parts[1], out var prefix))
+                        {
+                            options.KnownNetworks.Add(new IPNetwork(networkIp, prefix));
+                        }
+                    }
+                }
+            });
+        }
+
+        // 관리자단 비밀번호 정책 구성
+        public static void AddAdminIdentity(this IServiceCollection services, AppSettings settings)
+        {
+            services.AddIdentity<ApplicationUser, IdentityRole>(options =>
+            {
+                options.SignIn.RequireConfirmedAccount = true; // 이메일 확인 활성화
+
+                // Password settings.
+                options.Password.RequireDigit = true;
+                options.Password.RequireLowercase = true;
+                options.Password.RequireNonAlphanumeric = true;
+                options.Password.RequireUppercase = true;
+                options.Password.RequiredLength = 6;
+                options.Password.RequiredUniqueChars = 1;
+
+                // Lockout settings.
+                options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5);
+                options.Lockout.MaxFailedAccessAttempts = 5;
+                options.Lockout.AllowedForNewUsers = true;
+
+                // User settings.
+                options.User.AllowedUserNameCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._@+";
+                options.User.RequireUniqueEmail = true;
+            })
+            .AddDefaultUI() // 기본 UI 사용
+            .AddDefaultTokenProviders() // 기본 토큰 제공자 사용
+            .AddEntityFrameworkStores<IdentityDbContext>(); // 사용자 계정 저장소
+
+            // Identity Cookie Session Store 설정
+            services.AddSingleton<ITicketStore>(sp =>
+                new DistributedCacheTicketStore(
+                    sp.GetRequiredService<IDistributedCache>(),
+                    keyPrefix: settings.Redis.AuthTicketPrefix
+                )
+            );
+
+            services.AddOptions<CookieAuthenticationOptions>(IdentityConstants.ApplicationScheme).Configure<ITicketStore>((options, ticketStore) =>
+            {
+                options.SessionStore = ticketStore;
+            });
+
+            services.ConfigureApplicationCookie(options =>
+            {
+                // Cookie settings
+                options.ExpireTimeSpan = TimeSpan.FromMinutes(30);
+                options.LoginPath = "/Identity/Account/Login";
+                options.AccessDeniedPath = "/Identity/Account/AccessDenied";
+                options.SlidingExpiration = true;
+
+                options.Events.OnRedirectToLogin = context =>
+                {
+                    if (context.Request.Path.StartsWithSegments("/Api"))
+                    {
+                        context.Response.StatusCode = StatusCodes.Status401Unauthorized;
+                        return Task.CompletedTask;
+                    }
+
+                    context.Response.Redirect(context.RedirectUri); // 기존 쿠키 인증 흐름 유지
+                    return Task.CompletedTask;
+                };
+
+                options.Events.OnRedirectToAccessDenied = context =>
+                {
+                    if (context.Request.Path.StartsWithSegments("/Api"))
+                    {
+                        context.Response.StatusCode = StatusCodes.Status403Forbidden;
+                        return Task.CompletedTask;
+                    }
+
+                    context.Response.Redirect(context.RedirectUri);
+                    return Task.CompletedTask;
+                };
+            });
+        }
+    }
+}

+ 62 - 0
Admin/Extensions/WebApplicationExtensions.cs

@@ -0,0 +1,62 @@
+using Infrastructure.Persistence.Identity;
+using Microsoft.AspNetCore.Identity;
+
+namespace Admin.Extensions
+{
+    public static class WebApplicationExtensions
+    {
+        /// <summary>
+        /// 초기 관리자 계정 및 Admin 역할을 시드합니다.
+        /// 계정이 이미 존재하면 Admin 역할만 보장합니다.
+        /// </summary>
+        public static async Task SeedAdminAccountAsync(this WebApplication app)
+        {
+            using var scope = app.Services.CreateScope();
+
+            var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
+            var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
+
+            const string adminEmail = "jino@dpot.dev";
+            const string adminPassword = "Admin@1234";
+            const string adminRoleName = "Admin";
+
+            // Admin 역할이 없으면 생성
+            if (!await roleManager.RoleExistsAsync(adminRoleName))
+            {
+                await roleManager.CreateAsync(new IdentityRole(adminRoleName));
+            }
+
+            // 관리자 계정이 없으면 생성
+            var existingUser = await userManager.FindByEmailAsync(adminEmail);
+            if (existingUser == null)
+            {
+                var adminUser = new ApplicationUser
+                {
+                    UserName = adminEmail,
+                    Email = adminEmail,
+                    EmailConfirmed = true
+                };
+
+                var result = await userManager.CreateAsync(adminUser, adminPassword);
+                if (result.Succeeded)
+                {
+                    await userManager.AddToRoleAsync(adminUser, adminRoleName);
+                    Console.WriteLine($"[Seed] 관리자 계정 생성 완료: {adminEmail}");
+                }
+                else
+                {
+                    Console.WriteLine($"[Seed] 관리자 계정 생성 실패: {string.Join(", ", result.Errors.Select(e => e.Description))}");
+                }
+            }
+            else
+            {
+                // 기존 계정이 Admin 역할이 없으면 추가
+                if (!await userManager.IsInRoleAsync(existingUser, adminRoleName))
+                {
+                    await userManager.AddToRoleAsync(existingUser, adminRoleName);
+                    Console.WriteLine($"[Seed] 기존 계정에 Admin 역할 부여: {adminEmail}");
+                }
+            }
+        }
+    }
+}

+ 157 - 0
Admin/Middlewares/AdminAccessLogMiddleware.cs

@@ -0,0 +1,157 @@
+using SharedKernel.Constants;
+using Application.Abstractions.Data;
+using Domain.Entities.Director;
+using System.Diagnostics;
+
+namespace Admin.Middlewares;
+
+public class AdminAccessLogMiddleware(RequestDelegate next)
+{
+    // 로깅 제외 경로
+    private static readonly string[] ExcludePrefixes =
+    [
+        "/lib/",
+        "/css/",
+        "/js/",
+        "/images/",
+        "/favicon",
+        "/_framework",
+        "/_blazor",
+        "/.well-known/",
+        "/Identity/Account"
+    ];
+
+    // 로깅 제외 확장자
+    private static readonly string[] ExcludeExtensions =
+    [
+        ".css",
+        ".js",
+        ".map",
+        ".png",
+        ".jpg",
+        ".jpeg",
+        ".gif",
+        ".svg",
+        ".ico",
+        ".woff",
+        ".woff2",
+        ".ttf",
+        ".eot"
+    ];
+
+    public async Task InvokeAsync(HttpContext context)
+    {
+        var path = context.Request.Path.Value ?? "";
+
+        // 정적 파일, Identity 페이지 제외
+        if (ShouldSkip(path))
+        {
+            await next(context);
+            return;
+        }
+
+        // 인증되지 않은 사용자 제외
+        if (context.User.Identity is not { IsAuthenticated: true })
+        {
+            await next(context);
+            return;
+        }
+
+        var sw = Stopwatch.StartNew(); // 시작 시간
+
+        await next(context);
+
+        sw.Stop(); // 종료 시간
+
+        try
+        {
+            var db = context.RequestServices.GetRequiredService<IAppDbContext>();
+
+            var userID = context.User.Identity.Name ?? "-";
+            var userName = context.User.FindFirst(System.Security.Claims.ClaimTypes.GivenName)?.Value;
+            var method = context.Request.Method;
+            var queryString = context.Request.QueryString.HasValue ? context.Request.QueryString.Value : null;
+            var statusCode = context.Response.StatusCode;
+            var elapsedMs = sw.ElapsedMilliseconds;
+            var menuName = ResolveMenuName(path);
+            var ipAddress = context.Connection.RemoteIpAddress?.ToString();
+            var userAgent = context.Request.Headers.UserAgent.ToString();
+
+            var log = AdminAccessLog.Create(
+                userID,
+                userName,
+                method,
+                path,
+                queryString,
+                statusCode,
+                elapsedMs,
+                menuName,
+                ipAddress,
+                userAgent);
+
+            await db.AdminAccessLog.AddAsync(log);
+            await db.SaveChangesAsync();
+        }
+        catch
+        {
+
+        }
+    }
+
+    // 로깅 제외 경로 및 확장자 확인
+    private static bool ShouldSkip(string path)
+    {
+        foreach (var prefix in ExcludePrefixes)
+        {
+            if (path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
+            {
+                return true;
+            }
+        }
+
+        foreach (var ext in ExcludeExtensions)
+        {
+            if (path.EndsWith(ext, StringComparison.OrdinalIgnoreCase))
+            {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    // 경로에 해당하는 메뉴 이름 찾기
+    private static string? ResolveMenuName(string path)
+    {
+        var menus = Menus.GetMenus();
+
+        return FindMenuName(menus, path.TrimEnd('/'));
+    }
+
+    // 재귀적으로 메뉴 트리 탐색
+    private static string? FindMenuName(List<Menu> menus, string path)
+    {
+        foreach (var menu in menus)
+        {
+            if (!string.IsNullOrEmpty(menu.Path))
+            {
+                var menuPath = menu.Path.TrimEnd('/');
+                if (path.Equals(menuPath, StringComparison.OrdinalIgnoreCase) || path.StartsWith(menuPath + "/", StringComparison.OrdinalIgnoreCase))
+                {
+                    return menu.Name;
+                }
+            }
+
+            if (menu.Children is { Count: > 0 })
+            {
+                var childResult = FindMenuName(menu.Children, path);
+                if (childResult is not null)
+                {
+                    return childResult;
+                }
+            }
+        }
+
+        return null;
+    }
+}

+ 128 - 0
Admin/Middlewares/MenuAuthorizationMiddleware.cs

@@ -0,0 +1,128 @@
+using SharedKernel.Constants;
+
+namespace Admin.Middlewares;
+
+public class MenuAuthorizationMiddleware(RequestDelegate next)
+{
+    // 메뉴 경로 → 허용 역할 매핑 (앱 시작 시 1회 빌드)
+    private static readonly Lazy<List<(string Path, List<string> Roles)>> MenuRoleMap = new(() =>
+    {
+        var result = new List<(string Path, List<string> Roles)>();
+        var menus = Menus.GetMenus();
+        CollectMenuRoles(menus, result);
+
+        // 긴 경로 우선 매칭되도록 내림차순 정렬
+        result.Sort((a, b) => b.Path.Length.CompareTo(a.Path.Length));
+
+        return result;
+    });
+
+    public async Task InvokeAsync(HttpContext context)
+    {
+        // 인증되지 않은 사용자는 Identity가 처리 (로그인 페이지로 이동)
+        if (context.User.Identity is not { IsAuthenticated: true })
+        {
+            await next(context);
+            return;
+        }
+
+        var path = context.Request.Path.Value ?? "";
+
+        // 정적 파일, Identity, Error 페이지는 권한 체크 제외
+        if (ShouldSkip(path))
+        {
+            await next(context);
+            return;
+        }
+
+        // 메뉴에 등록된 경로인지 확인
+        var matchedRoles = FindMatchingRoles(path);
+
+        if (matchedRoles != null)
+        {
+            // 역할 중 하나라도 가지고 있으면 허용
+            var hasAccess = matchedRoles.Any(role => context.User.IsInRole(role));
+
+            if (!hasAccess)
+            {
+                context.Response.StatusCode = 403;
+                context.Response.Redirect("/Identity/Account/AccessDenied");
+                return;
+            }
+        }
+
+        await next(context);
+    }
+
+    private static List<string>? FindMatchingRoles(string path)
+    {
+        var trimmedPath = path.TrimEnd('/');
+
+        if (string.IsNullOrEmpty(trimmedPath))
+        {
+            return null;
+        }
+
+        foreach (var (menuPath, roles) in MenuRoleMap.Value)
+        {
+            if (trimmedPath.Equals(menuPath, StringComparison.OrdinalIgnoreCase) || trimmedPath.StartsWith(menuPath + "/", StringComparison.OrdinalIgnoreCase))
+            {
+                return roles;
+            }
+        }
+
+        return null;
+    }
+
+    // 권한 체크 제외 경로
+    private static readonly string[] SkipPrefixes =
+    [
+        "/Identity/",
+        "/lib/",
+        "/css/",
+        "/js/",
+        "/images/",
+        "/favicon",
+        "/_framework",
+        "/_blazor",
+        "/.well-known/",
+        "/Error"
+    ];
+
+    private static bool ShouldSkip(string path)
+    {
+        // 확장자가 있으면 정적 파일로 간주
+        var lastSegment = path.AsSpan(path.LastIndexOf('/') + 1);
+        if (lastSegment.Contains('.'))
+        {
+            return true;
+        }
+
+        foreach (var prefix in SkipPrefixes)
+        {
+            if (path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
+            {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    // 메뉴 트리에서 경로와 역할을 수집하는 재귀 메서드
+    private static void CollectMenuRoles(List<Menu> menus, List<(string Path, List<string> Roles)> result)
+    {
+        foreach (var menu in menus)
+        {
+            if (!string.IsNullOrEmpty(menu.Path) && menu.Roles is { Count: > 0 })
+            {
+                result.Add((menu.Path.TrimEnd('/'), menu.Roles));
+            }
+
+            if (menu.Children is { Count: > 0 })
+            {
+                CollectMenuRoles(menu.Children, result);
+            }
+        }
+    }
+}

+ 153 - 0
Admin/Pages/Banner/List/Edit.cshtml

@@ -0,0 +1,153 @@
+@page "{id:int}"
+@model Admin.Pages.Banner.List.EditModel
+
+@{
+    ViewData["Title"] = "배너 수정";
+}
+
+<div class="container">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <div asp-validation-summary="ModelOnly" class="text-danger"></div>
+    <partial name="_StatusMessage" />
+
+    <form id="fAdminWrite" method="post" accept-charset="utf-8" autocomplete="off" enctype="multipart/form-data">
+        <input type="hidden" asp-for="Input.ID" />
+        <input type="hidden" asp-for="QueryString" />
+
+        <div class="row mb-2">
+            <label asp-for="Input.PositionID" class="col-sm-2 col-form-label"><span>*</span> 배너 위치</label>
+            <div class="col-sm-10">
+                <div class="row">
+                    <div class="col col-md-auto">
+                        <select asp-for="Input.PositionID" class="form-select" asp-items="Model.Positions" required></select>
+                    </div>
+                </div>
+                <span asp-validation-for="Input.PositionID" class="text-danger"></span>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Input.Subject" class="col-sm-2 col-form-label"><span>*</span> 배너 명</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Subject" class="form-control" required />
+                <span asp-validation-for="Input.Subject" class="text-danger"></span>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Input.Link" class="col-sm-2 col-form-label"></label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Link" class="form-control" />
+                <span asp-validation-for="Input.Link" class="text-danger"></span>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label class="col-sm-2 col-form-label">현재 이미지</label>
+            <div class="col-sm-10">
+                <div class="mb-2">
+                    <div class="form-text">
+                        Desktop
+                        @if (Model.CurrentDesktopImage != null)
+                        {
+                            <br/>
+                            <img src="@Model.CurrentDesktopImage" class="img-thumbnail img-fluid rounded" />
+                        } else {
+                            <text>-</text>
+                        }
+                    </div>
+                </div>
+                <div>
+                    <div class="form-text">
+                        Mobile
+                        @if (Model.CurrentMobileImage != null)
+                        {
+                            <br />
+                            <img src="@Model.CurrentMobileImage" class="img-thumbnail img-fluid rounded" />
+                        }
+                        else
+                        {
+                            <text>-</text>
+                        }
+                    </div>
+                </div>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label class="col-sm-2 col-form-label">이미지</label>
+            <div class="col-sm-10">
+                <div class="mb-3">
+                    <label asp-for="Input.DesktopImageFile" class="form-label">Desktop</label>
+                    <div id="DesktopBannerPrev" hidden>
+                        <img class="img-fluid img-thumbnail" alt="이미지(Desktop) 미리보기" /><br />
+                        <button type="button" class="btn btn-sm btn-danger mt-2 mb-2 btn-remove-preview">삭제</button>
+                    </div>
+                    <input asp-for="Input.DesktopImageFile" type="file" class="form-control" accept="image/*" />
+                    <span asp-validation-for="Input.DesktopImageFile" class="text-danger"></span>
+                </div>
+                <div>
+                    <label asp-for="Input.MobileImageFile" class="form-label">Mobile</label>
+                    <div id="MobileBannerPrev" hidden>
+                        <img class="img-fluid img-thumbnail" alt="이미지(Mobile) 미리보기" /><br />
+                        <button type="button" class="btn btn-sm btn-danger mt-2 mb-2 btn-remove-preview">삭제</button>
+                    </div>
+                    <input asp-for="Input.MobileImageFile" type="file" class="form-control" accept="image/*" />
+                    <span asp-validation-for="Input.MobileImageFile" class="text-danger"></span>
+                </div>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Input.Order" class="col-sm-2 col-form-label"><span class="text-danger">*</span> 순서</label>
+            <div class="col-sm-10">
+                <div class="row">
+                    <div class="col col-md-auto">
+                        <input asp-for="Input.Order" class="form-control" type="number" min="-9999" max="9999" required />
+                    </div>
+                </div>
+                <span asp-validation-for="Input.Order" class="text-danger"></span>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label class="col-sm-2 col-form-label">사용 기간</label>
+            <div class="col-sm-10">
+                <div class="row g-2">
+                    <div class="col col-md-auto">
+                        <input asp-for="Input.StartAt" class="form-control" />
+                        <span asp-validation-for="Input.StartAt" class="text-danger"></span>
+                    </div>
+                    <div class="col-auto d-none d-md-block align-self-center">~</div>
+                    <div class="col col-md-auto">
+                        <input asp-for="Input.EndAt" class="form-control" />
+                        <span asp-validation-for="Input.EndAt" class="text-danger"></span>
+                    </div>
+                </div>
+                <span class="text-muted form-text">
+                    사용 기간을 설정하지 않으면 무제한으로 사용됩니다.
+                </span>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Input.IsActive" class="col-sm-2 col-form-label">사용 여부</label>
+            <div class="col-sm-10 align-content-center">
+                <div class="form-check-inline">
+                    <input type="checkbox" asp-for="Input.IsActive" class="form-check-input" />
+                    <label class="form-check-label" asp-for="Input.IsActive">사용합니다.</label>
+                    <span asp-validation-for="Input.IsActive" class="text-danger"></span>
+                </div>
+            </div>
+        </div>
+        <hr/>
+        <div class="row">
+            <div class="col text-center p-3">
+                <button type="submit" class="btn btn-success">저장</button>
+                <a href="/Banner/List?@Model.QueryString" class="btn btn-secondary">취소</a>
+            </div>
+        </div>
+        <br/>
+    </form>
+</div>
+@section Scripts {
+    <script>
+        setupImagePreview("Input_DesktopImageFile", "DesktopBannerPrev");
+        setupImagePreview("Input_MobileImageFile", "MobileBannerPrev");
+    </script>
+}

+ 129 - 0
Admin/Pages/Banner/List/Edit.cshtml.cs

@@ -0,0 +1,129 @@
+using SharedKernel.Attributes;
+using SharedKernel.Extensions;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.Mvc.Rendering;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Banner.List;
+
+public class EditModel(IMediator mediator) : PageModel
+{
+    [BindProperty]
+    public string? QueryString { get; set; }
+    public List<SelectListItem> Positions { get; set; } = [];
+
+    public string? CurrentDesktopImage { get; private set; }
+    public string? CurrentMobileImage { get; private set; }
+
+    [BindProperty]
+    public InputModel Input { get; set; } = new();
+
+    public sealed class InputModel
+    {
+        [DisplayName("ID")]
+        [Required(ErrorMessage = "{0}는 필수입니다.")]
+        public int ID { get; set; }
+
+        [DisplayName("배너 위치 ID")]
+        [Required(ErrorMessage = "{0}는 필수입니다.")]
+        public int PositionID { get; set; }
+
+        [DisplayName("배너 명")]
+        [DataType(DataType.Text)]
+        [Required(ErrorMessage = "{0}는 필수입니다.")]
+        [StringLength(255, ErrorMessage = "{0}은 {1}자 이하로 입력하세요.")]
+        public string Subject { get; set; } = null!;
+
+        [DisplayName("첨부 이미지(Desktop)")]
+        [AllowedExtensions("jpg,jpeg,png,gif,webp", ErrorMessage = "이미지 파일은 jpg, jpeg, png, gif, webp 형식이어야 합니다.")]
+        public IFormFile? DesktopImageFile { get; set; }
+
+        [DisplayName("첨부 이미지(Mobile)")]
+        [AllowedExtensions("jpg,jpeg,png,gif,webp", ErrorMessage = "이미지 파일은 jpg, jpeg, png, gif, webp 형식이어야 합니다.")]
+        public IFormFile? MobileImageFile { get; set; }
+
+        [DisplayName("주소")]
+        [DataType(DataType.Url)]
+        [StringLength(255, ErrorMessage = "{0}은 {1}자 이하로 입력하세요.")]
+        public string? Link { get; set; }
+
+        [DisplayName("순서")]
+        [Required(ErrorMessage = "{0}는 필수입니다.")]
+        [Range(-9999, 9999, ErrorMessage = "{0} 허용 범위는 {2} ~ {1} 입니다.")]
+        public short Order { get; set; } = 0;
+
+        [DisplayName("사용 여부")]
+        public bool IsActive { get; set; } = false;
+
+        [DisplayName("사용 기간 - 시작")]
+        [DataType(DataType.DateTime)]
+        public DateTime? StartAt { get; set; }
+
+        [DisplayName("사용 기간 - 종료")]
+        [DataType(DataType.DateTime)]
+        public DateTime? EndAt { get; set; }
+    }
+
+    public async Task OnGetAsync(int id, CancellationToken ct)
+    {
+        Positions = [.. (await mediator.Send(new GetBannerPositions.Query(), ct)).List.Select(p => new SelectListItem {
+            Value = p.ID.ToString(),
+            Text = $"[{p.Code}] {p.Subject}"
+        })];
+
+        var result = await mediator.Send(new GetBannerItem.Query(id), ct);
+
+        CurrentDesktopImage = result.DesktopImage;
+        CurrentMobileImage = result.MobileImage;
+
+        Input = new InputModel
+        {
+            ID = result.ID,
+            PositionID = result.PositionID,
+            Subject = result.Subject,
+            Link = result.Link,
+            Order = result.Order,
+            IsActive = result.IsActive,
+            StartAt = result.StartAt,
+            EndAt = result.EndAt
+        };
+
+        QueryString = Request.QueryString.ToString();
+    }
+
+    public async Task<IActionResult> OnPostAsync(CancellationToken ct)
+    {
+        try
+        {
+            if (!ModelState.IsValid)
+            {
+                throw new Exception(ModelState.GetErrorMessages());
+            }
+
+            await mediator.Send(new UpdateBannerItem.Command(
+                Input.ID,
+                Input.PositionID,
+                Input.Subject,
+                Input.DesktopImageFile,
+                Input.MobileImageFile,
+                Input.Link,
+                Input.Order,
+                Input.IsActive,
+                Input.StartAt,
+                Input.EndAt
+            ), ct);
+
+            TempData["SuccessMessage"] = $"{Input.Subject} 배너가 수정되었습니다.";
+        }
+        catch (Exception e)
+        {
+            TempData["ErrorMessages"] = e.Message;
+
+        }
+
+        return Redirect($"/Banner/List/Edit/{Input.ID}{Request.QueryString}");
+    }
+}

+ 141 - 0
Admin/Pages/Banner/List/Index.cshtml

@@ -0,0 +1,141 @@
+@page
+@model Admin.Pages.Banner.List.IndexModel
+@{
+    ViewData["Title"] = "배너 관리";
+}
+
+<div class="container-fluid">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <partial name="_StatusMessage" />
+    <partial name="_NavTabs" />
+
+    <div class="row g-2 mb-2 mb-sm-0 mt-2">
+        <div class="col-auto">
+            <select name="positionID" id="positionID" class="form-select w-auto" form="fAdminSearch">
+                <option value="">선택하세요.</option>
+                @foreach ((int ID, string Subject, int BannerItemRows) row in Model.Positions)
+                {
+                    <option value="@row.ID" selected="@(row.ID == Model.Query.PositionID)">@row.Subject (@row.BannerItemRows)</option>
+                }
+            </select>
+        </div>
+        <div class="col col-sm-auto">
+            <input type="search" name="keyword" class="form-control" value="@Model.Query.Keyword" placeholder="배너 명" form="fAdminSearch" />
+        </div>
+        <div class="col-auto col-sm">
+            <button type="submit" class="btn btn-success" form="fAdminSearch">검색</button>
+        </div>
+    </div>
+
+    <div class="row g-2 align-items-end">
+        <div class="col">
+            Total : @Model.Total
+        </div>
+        <div class="col-auto">
+            <select name="perPage" id="perPage" class="form-select w-auto d-inline-block" form="fAdminSearch">
+                <option value="10" selected="@(Model.Query.PerPage == 10)">10</option>
+                <option value="20" selected="@(Model.Query.PerPage == 20)">20</option>
+                <option value="50" selected="@(Model.Query.PerPage == 50)">50</option>
+                <option value="100" selected="@(Model.Query.PerPage == 100)">100</option>
+            </select>
+        </div>
+        <div class="col-auto">
+            <button type="button" id="btnListDelete" class="btn btn-danger" form="fAdminList" disabled>삭제</button>
+            <a class="btn btn-success" asp-page="/Banner/List/Write">추가</a>
+        </div>
+    </div>
+
+    <div class="table-responsive">
+        <table class="table table-striped table-bordered table-hover mt-3">
+            <colgroup>
+                <col />
+                <col style="width: 10%" />
+                <col style="width: 30%" />
+                <col />
+                <col />
+                <col />
+                <col />
+                <col />
+            </colgroup>
+            <thead>
+                <tr>
+                    <th>
+                        <div class="form-check form-check-inline">
+                            <input type="checkbox" id="checkedAll" class="form-check-input" value="1" form="fAdminList" />
+                            <label for="checkedAll">ID</label>
+                        </div>
+                    </th>
+                    <th>위치</th>
+                    <th>배너 명</th>
+                    <th>순서</th>
+                    <th>사용</th>
+                    <th>등록일시</th>
+                    <th>수정일시</th>
+                    <th>비고</th>
+                </tr>
+            </thead>
+            <tbody>
+                @if (Model.List == null || Model.List.Count <= 0)
+                {
+                    <tr>
+                        <td colspan="8">No Data.</td>
+                    </tr>
+                }
+                else
+                {
+                    @foreach (var row in Model.List)
+                    {
+                        <tr>
+                            <td>
+                                <div class="form-check form-check-inline">
+                                    <input type="checkbox" name="ids[]" id="ids_@row.ID" class="form-check-input list-check-box" value="@row.ID" form="fAdminList" />
+                                    <label for="ids_@row.ID">@row.ID</label>
+                                </div>
+                            </td>
+                            <td>[@row.PositionCode] @row.PositionSubject</td>
+                            <td>@row.Subject</td>
+                            <td>@row.Order</td>
+                            <td>@row.IsActive</td>
+                            <td>@row.CreatedAt</td>
+                            <td>@row.UpdatedAt</td>
+                            <td>
+                                <div class="d-grid gap-2 d-block d-xxl-inline">
+                                    <a class="btn btn-sm btn-outline-info" href="@row.EditURL">수정</a>
+                                    <button type="button" class="btn btn-sm btn-outline-danger btn-row-delete" data-id="@row.ID">삭제</button>
+                                </div>
+                            </td>
+                        </tr>
+                    }
+                }
+            </tbody>
+        </table>
+
+        <partial name="_Pagination" model="@Model.Pagination" />
+    </div>
+</div>
+
+<!-- 검색을 위한 -->
+<form id="fAdminSearch" method="get" accept-charset="utf-8">
+    <input type="hidden" name="pageNum" value="@Model.Query.PageNum" />
+</form>
+
+<!-- 삭제를 위한 -->
+<form id="fAdminList" method="post" accept-charset="utf-8" asp-page-handler="Delete">
+    @Html.AntiForgeryToken()
+    <input type="hidden" name="pageNum" value="@Model.Query.PageNum" />
+    <input type="hidden" name="perPage" value="@Model.Query.PerPage" />
+    <input type="hidden" name="positionID" value="@Model.Query.PositionID" />
+    <input type="hidden" name="keyword" value="@Model.Query.Keyword" />
+</form>
+
+@section Scripts {
+    <script>
+        let searchForm = document.getElementById("fAdminSearch");
+
+        $(document).on("change", "#positionID, #perPage", function () {
+           searchForm.submit();
+        });
+    </script>
+}

+ 95 - 0
Admin/Pages/Banner/List/Index.cshtml.cs

@@ -0,0 +1,95 @@
+using SharedKernel.Extensions;
+using SharedKernel.Helpers;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Banner.List;
+
+public class IndexModel(IMediator mediator) : PageModel
+{
+    [BindProperty(SupportsGet = true)]
+    public QueryParams Query { get; set; } = new();
+
+    public List<(int ID, string Subject, int BannerItemRows)> Positions { get; set; } = [];
+
+    public sealed class QueryParams
+    {
+        [Range(1, int.MaxValue)]
+        [DisplayName("페이지 번호")]
+        public int PageNum { get; set; } = 1;
+
+        [Range(1, 100)]
+        [DisplayName("페이지 목록 수")]
+        public ushort PerPage { get; set; } = 10;
+
+        [DisplayName("배너 위치")]
+        public int? PositionID { get; set; }
+
+        [DisplayName("검색어")]
+        public string? Keyword { get; set; }
+    }
+
+    public int Total { get; set; }
+
+    public List<(
+        int Num,
+        int ID,
+        string PositionCode,
+        string PositionSubject,
+        string Subject,
+        short Order,
+        char IsActive,
+        string? UpdatedAt,
+        string CreatedAt,
+        string EditURL
+    )> List { get; set; } = [];
+
+    public Pagination? Pagination { get; set; }
+
+    public async Task OnGetAsync(CancellationToken ct)
+    {
+        if (!ModelState.IsValid)
+        {
+            return;
+        }
+
+        Positions = [.. (await mediator.Send(new GetBannerPositions.Query(), ct)).List.Select(c => (c.ID, c.Subject, c.BannerItemRows))];
+
+        var result = await mediator.Send(new SearchBannerItems.Query(Query.PositionID, Query.Keyword, Query.PageNum, Query.PerPage), ct);
+
+        Total = result.Total;
+        List = [..result.List.Select(c => (
+            c.Num,
+            c.ID,
+            c.PositionCode,
+            c.PositionSubject,
+            c.Subject,
+            c.Order,
+            c.IsActive ? 'Y' : 'N',
+            c.UpdatedAt.GetDateAt() ?? "-",
+            c.CreatedAt.GetDateAt(),
+            EditURL: $"/Banner/List/Edit/{c.ID}{Request.QueryString}"
+        ))];
+
+        Pagination = new Pagination(result.Total, Query.PageNum, Query.PerPage);
+    }
+
+    public async Task<IActionResult> OnPostDeleteAsync(int[] ids, CancellationToken ct)
+    {
+        try
+        {
+            await mediator.Send(new DeleteBannerItem.Command(ids), ct);
+
+            TempData["SuccessMessage"] = $"{ids.Length}개 배너가 삭제되었습니다.";
+        }
+        catch (Exception e)
+        {
+            TempData["ErrorMessages"] = e.Message;
+        }
+
+        return RedirectToPage("/Banner/List/Index", Query);
+    }
+}

+ 117 - 0
Admin/Pages/Banner/List/Write.cshtml

@@ -0,0 +1,117 @@
+@page
+@model Admin.Pages.Banner.List.WriteModel
+@{
+    ViewData["Title"] = "배너 등록";
+}
+
+<div class="container">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <partial name="_StatusMessage" />
+
+    <form id="fAdminWrite" method="post" accept-charset="utf-8" autocomplete="off" enctype="multipart/form-data">
+        <div class="row mb-2">
+            <label asp-for="Input.PositionID" class="col-sm-2 col-form-label"><span>*</span> 배너 위치</label>
+            <div class="col-sm-10">
+                <div class="row">
+                    <div class="col col-md-auto">
+                        <select asp-for="Input.PositionID" class="form-select" asp-items="Model.Positions" required></select>
+                    </div>
+                </div>
+                <span asp-validation-for="Input.PositionID" class="text-danger"></span>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Input.Subject" class="col-sm-2 col-form-label"><span>*</span> 배너 명</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Subject" class="form-control" required/>
+                <span asp-validation-for="Input.Subject" class="text-danger"></span>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Input.Link" class="col-sm-2 col-form-label"></label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Link" class="form-control" type="url" />
+                <span asp-validation-for="Input.Link" class="text-danger"></span>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label class="col-sm-2 col-form-label">이미지</label>
+            <div class="col-sm-10">
+                <div class="mb-3">
+                    <label asp-for="Input.DesktopImageFile" class="form-label">Desktop</label>
+                    <div id="DesktopBannerPrev" hidden>
+                        <img class="img-fluid img-thumbnail" alt="이미지(Desktop) 미리보기" /><br/>
+                        <button type="button" class="btn btn-sm btn-danger mt-2 mb-2 btn-remove-preview">삭제</button>
+                    </div>
+                    <input asp-for="Input.DesktopImageFile" type="file" class="form-control" accept="image/*" />
+                    <span asp-validation-for="Input.DesktopImageFile" class="text-danger"></span>
+                </div>
+                <div>
+                    <label asp-for="Input.MobileImageFile" class="form-label">Mobile</label>
+                    <div id="MobileBannerPrev" hidden>
+                        <img class="img-fluid img-thumbnail" alt="이미지(Mobile) 미리보기" /><br/>
+                        <button type="button" class="btn btn-sm btn-danger mt-2 mb-2 btn-remove-preview">삭제</button>
+                    </div>
+                    <input asp-for="Input.MobileImageFile" type="file" class="form-control" accept="image/*" />
+                    <span asp-validation-for="Input.MobileImageFile" class="text-danger"></span>
+                </div>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Input.Order" class="col-sm-2 col-form-label"><span class="text-danger">*</span> 순서</label>
+            <div class="col-sm-10">
+                <div class="row">
+                    <div class="col col-md-auto">
+                        <input asp-for="Input.Order" class="form-control" type="number" min="-9999" max="9999" required />
+                    </div>
+                </div>
+                <span asp-validation-for="Input.Order" class="text-danger"></span>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label class="col-sm-2 col-form-label">사용 기간</label>
+            <div class="col-sm-10">
+                <div class="row g-2">
+                    <div class="col col-md-auto">
+                        <input asp-for="Input.StartAt" class="form-control" />
+                        <span asp-validation-for="Input.StartAt" class="text-danger"></span>
+                    </div>
+                    <div class="col-auto d-none d-md-block align-self-center">~</div>
+                    <div class="col col-md-auto">
+                        <input asp-for="Input.EndAt" class="form-control" />
+                        <span asp-validation-for="Input.EndAt" class="text-danger"></span>
+                    </div>
+                </div>
+                <span class="text-muted form-text">
+                    사용 기간을 설정하지 않으면 무제한으로 사용됩니다.
+                </span>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Input.IsActive" class="col-sm-2 col-form-label">사용 여부</label>
+            <div class="col-sm-10 align-content-center">
+                <div class="form-check-inline">
+                    <input type="checkbox" asp-for="Input.IsActive" class="form-check-input" />
+                    <label class="form-check-label" asp-for="Input.IsActive">사용합니다.</label>
+                    <span asp-validation-for="Input.IsActive" class="text-danger"></span>
+                </div>
+            </div>
+        </div>
+        <hr />
+        <div class="row">
+            <div class="col text-center p-3">
+                <button type="submit" class="btn btn-success">저장</button>
+                <a href="/Banner/List?@Model.QueryString" class="btn btn-secondary">취소</a>
+            </div>
+        </div>
+        <br/>
+    </form>
+</div>
+@section Scripts {
+    <script>
+        setupImagePreview("Input_DesktopImageFile", "DesktopBannerPrev");
+        setupImagePreview("Input_MobileImageFile", "MobileBannerPrev");
+    </script>
+}

+ 104 - 0
Admin/Pages/Banner/List/Write.cshtml.cs

@@ -0,0 +1,104 @@
+using SharedKernel.Attributes;
+using SharedKernel.Extensions;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.Mvc.Rendering;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Banner.List;
+
+public class WriteModel(IMediator mediator) : PageModel
+{
+    public string? QueryString { get; set; }
+    public List<SelectListItem> Positions { get; set; } = [];
+
+    [BindProperty]
+    public InputModel Input { get; set; } = new();
+
+    public sealed class InputModel
+    {
+        [DisplayName("배너 위치 ID")]
+        [Required(ErrorMessage = "{0}는 필수입니다.")]
+        public int PositionID { get; set; }
+
+        [DisplayName("배너 명")]
+        [DataType(DataType.Text)]
+        [Required(ErrorMessage = "{0}는 필수입니다.")]
+        [StringLength(255, ErrorMessage = "{0}은 {1}자 이하로 입력하세요.")]
+        public string Subject { get; set; } = null!;
+
+        [DisplayName("첨부 이미지(Desktop)")]
+        [AllowedExtensions("jpg,jpeg,png,gif,webp", ErrorMessage = "이미지 파일은 jpg, jpeg, png, gif, webp 형식이어야 합니다.")]
+        public IFormFile? DesktopImageFile { get; set; }
+
+        [DisplayName("첨부 이미지(Mobile)")]
+        [AllowedExtensions("jpg,jpeg,png,gif,webp", ErrorMessage = "이미지 파일은 jpg, jpeg, png, gif, webp 형식이어야 합니다.")]
+        public IFormFile? MobileImageFile { get; set; }
+
+        [DisplayName("주소")]
+        [DataType(DataType.Url)]
+        [StringLength(255, ErrorMessage = "{0}은 {1}자 이하로 입력하세요.")]
+        public string? Link { get; set; }
+
+        [DisplayName("순서")]
+        [Required(ErrorMessage = "{0}는 필수입니다.")]
+        [Range(-9999, 9999, ErrorMessage = "{0} 허용 범위는 {2} ~ {1} 입니다.")]
+        public short Order { get; set; } = 0;
+
+        [DisplayName("사용 여부")]
+        public bool IsActive { get; set; } = false;
+
+        [DisplayName("사용 기간 - 시작")]
+        [DataType(DataType.DateTime)]
+        public DateTime? StartAt { get; set; }
+
+        [DisplayName("사용 기간 - 종료")]
+        [DataType(DataType.DateTime)]
+        public DateTime? EndAt { get; set; }
+    }
+
+    public async Task OnGetAsync(CancellationToken ct)
+    {
+        Positions = [.. (await mediator.Send(new GetBannerPositions.Query(), ct)).List.Select(p => new SelectListItem {
+            Value = p.ID.ToString(),
+            Text = $"[{p.Code}] {p.Subject}"
+        })];
+
+        QueryString = Request.QueryString.ToString();
+    }
+
+    public async Task<IActionResult> OnPostAsync(CancellationToken ct)
+    {
+        try
+        {
+            if (!ModelState.IsValid)
+            {
+                throw new Exception(ModelState.GetErrorMessages());
+            }
+
+            await mediator.Send(new CreateBannerItem.Command(
+                Input.PositionID,
+                Input.Subject,
+                Input.DesktopImageFile,
+                Input.MobileImageFile,
+                Input.Link,
+                Input.Order,
+                Input.IsActive,
+                Input.StartAt,
+                Input.EndAt
+            ), ct);
+
+            TempData["SuccessMessage"] = $"{Input.Subject} 배너가 등록되었습니다.";
+
+            return RedirectToPage("/Banner/List/Index");
+        }
+        catch (Exception e)
+        {
+            TempData["ErrorMessages"] = e.Message;
+
+            return Redirect($"/Banner/List/Write?{Request.QueryString}");
+        }
+    }
+}

+ 194 - 0
Admin/Pages/Banner/Position.cshtml

@@ -0,0 +1,194 @@
+@page
+@model Admin.Pages.Banner.PositionModel
+@{
+    ViewData["Title"] = "배너 위치";
+}
+
+<div class="container-fluid">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <partial name="_StatusMessage" />
+    <partial name="_NavTabs" />
+
+    <div class="row g-2 align-items-end mt-2">
+        <div class="col">
+            Total : @Model.Total
+        </div>
+        <div class="col text-end">
+            <button type="button" id="btnAdd" class="btn btn-primary" form="fAdminWrite">추가</button>
+            <button type="submit" id="btnSave" class="btn btn-success" form="fAdminWrite">저장</button>
+        </div>
+    </div>
+
+    <div class="table-responsive">
+        <form id="fAdminWrite" method="post" accept-charset="utf-8" autocomplete="off"></form>
+
+        <table class="table table-striped table-bordered table-hover mt-3">
+            <caption>
+                배너 위치에 등록된 배너가 있다면 삭제가 불가합니다.<br />
+                배너 위치를 삭제하려면 해당 배너를 먼저 삭제해주세요.
+            </caption>
+            <colgroup>
+                <col style="width: 5%;" />
+                <col />
+                <col />
+                <col />
+                <col />
+                <col />
+                <col />
+                <col />
+            </colgroup>
+            <thead>
+                <tr class="text-center">
+                    <th>ID</th>
+                    <th>Code</th>
+                    <th>위치 명</th>
+                    <th>배너 수</th>
+                    <th>사용</th>
+                    <th>등록일시</th>
+                    <th>수정일시</th>
+                    <th>비고</th>
+                </tr>
+            </thead>
+            <tbody id="positions">
+                @if (Model.List is null || Model.List.Count <= 0)
+                {
+                    <tr>
+                        <td colspan="8">No Data.</td>
+                    </tr>
+                }
+                else
+                {
+                    @foreach (var row in Model.List)
+                    {
+                        var index = row.Index;
+
+                        <tr>
+                            <td>
+                                <input type="text" readonly class="form-control-plaintext text-center @(row.BannerItemRows > 0 ? "text-white bg-danger" : "")" value="@row.Num" />
+                                <input type="hidden" name="request[@index].ID" readonly class="form-control-plaintext text-center" required form="fAdminWrite" value="@row.ID" />
+                            </td>
+                            <td>
+                                <input type="text" name="request[@index].Code" class="form-control" maxlength="30" required form="fAdminWrite" value="@row.Code" />
+                            </td>
+                            <td>
+                                <input type="text" name="request[@index].Subject" class="form-control" maxlength="255" required form="fAdminWrite" value="@row.Subject" />
+                            </td>
+                            <td>@row.BannerItemRows</td>
+                            <td>
+                                <div class="form-check-inline">
+                                    <input class="form-check-input" type="checkbox" id="request_@(index)_IsActive" name="request[@index].IsActive" checked="@Model.Data[index].IsActive" form="fAdminWrite" value="true" />
+                                    <label class="form-check-label" for="request_@(index)_IsActive">사용</label>
+                                </div>
+                            </td>
+                            <td><input type="text" readonly class="form-control-plaintext text-center" form="fAdminWrite" value="@row.CreatedAt" /></td>
+                            <td><input type="text" readonly class="form-control-plaintext text-center" form="fAdminWrite" value="@row.UpdatedAt" /></td>
+                            <td>
+                                <button type="button" class="btn btn-sm btn-danger btn-delete">삭제</button>
+                            </td>
+                        </tr>
+                    }
+                }
+            </tbody>
+        </table>
+    </div>
+</div>
+
+@section Scripts {
+    <script>
+        $(function() {
+            let $positions = $("#positions");
+            let total = Number(@Model.Total);
+
+            // 추가
+            $(document).on("click", "#btnAdd", function() {
+                if (total <= 0) {
+                    $positions.empty();
+                }
+
+                let tableRow = `
+                    <tr>
+                        <td>-</td>
+                        <td>
+                            <input type="text" name="request[${total}].Code" class="form-control" maxlength="30" required form="fAdminWrite" />
+                        </td>
+                        <td>
+                            <input type="text" name="request[${total}].Subject" class="form-control" maxlength="255" required form="fAdminWrite" />
+                        </td>
+                        <td>-</td>
+                        <td>
+                            <div class="form-check-inline">
+                                <input class="form-check-input" type="checkbox" id="request_${total}_IsActive" name="request[${total}].IsActive" checked form="fAdminWrite" value="true" />
+                                <label class="form-check-label" for="request_${total}_IsActive">사용</label>
+                            </div>
+                        </td>
+                        <td>-</td>
+                        <td>-</td>
+                        <td>
+                            <button type="button" class="btn btn-danger btn-sm btn-delete">삭제</button>
+                        </td>
+                    </tr>
+                `;
+
+                $positions.append(tableRow);
+                total++;
+                recalculateIndices();
+            });
+
+            // 삭제
+            $(document).on("click", "button.btn-delete", function(e) {
+                e.target.closest("tr").remove();
+                total--;
+
+                if (total <= 0) {
+                    $positions.append(`<tr><td colspan="8">No Data.</td></tr>`);
+                    total = 0;
+                } else {
+                    recalculateIndices();
+                }
+            });
+
+            // 저장
+            $(document).on("click", "#btnSave", function() {
+                if (confirm("저장 하시겠습니까?")) {
+                    let form = document.getElementById("fAdminWrite");
+                    if (form.checkValidity()) {
+                        form.submit();
+                    } else {
+                        form.reportValidity();
+                    }
+                }
+                return false;
+            });
+
+            function recalculateIndices() {
+                $positions.find("tr").each(function(index, tr) {
+                    $(tr)
+                        .find("input, label")
+                        .each(function() {
+                            let name = $(this).attr("name");
+                            let id = $(this).attr("id");
+
+                            if (name) {
+                                $(this).attr("name", name.replace(/\[\d+\]/, `[${index}]`));
+                            }
+
+                            if (id) {
+                                $(this).attr("id", id.replace(/_\d+_/, `_${index}_`));
+                            }
+                        });
+
+                    $(tr)
+                        .find("label")
+                        .each(function() {
+                            let labelFor = $(this).attr("for");
+                            if (labelFor) {
+                                $(this).attr("for", labelFor.replace(/_\d+_/, `_${index}_`));
+                            }
+                        });
+                });
+            }
+        });
+    </script>
+}

+ 105 - 0
Admin/Pages/Banner/Position.cshtml.cs

@@ -0,0 +1,105 @@
+using SharedKernel.Extensions;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Banner;
+
+public class PositionModel(IMediator mediator) : PageModel
+{
+    public int Total { get; private set; }
+
+    public List<(
+        int Num,
+        int ID,
+        int Index,
+        string Code,
+        string Subject,
+        char IsActive,
+        int BannerItemRows,
+        string? UpdatedAt,
+        string CreatedAt
+    )> List { get; set; } = [];
+
+    [BindProperty(Name = "request")]
+    public List<InputModel> Input { get; private set; } = [];
+
+    public List<InputModel> Data { get; private set; } = [];
+
+    public sealed class InputModel
+    {
+        public int? ID { get; set; }
+
+        [Required]
+        [StringLength(30)]
+        public required string Code { get; set; }
+
+        [Required]
+        [StringLength(255)]
+        public required string Subject { get; set; }
+
+        public bool IsActive { get; set; }
+    }
+
+    public async Task OnGetAsync(CancellationToken ct)
+    {
+        if (!ModelState.IsValid)
+        {
+            return;
+        }
+
+        var result = await mediator.Send(new GetBannerPositions.Query(), ct);
+
+        Total = result.Total;
+        List = [..result.List.Select(c => (
+            c.Num,
+            c.ID,
+            c.Index,
+            c.Code,
+            c.Subject,
+            c.IsActive ? 'Y' : 'N',
+            c.BannerItemRows,
+            c.UpdatedAt.GetDateAt() ?? "-",
+            c.CreatedAt.GetDateAt()
+        ))];
+
+        Data = [..result.List.Select(x => new InputModel
+        {
+            ID = x.ID,
+            Code = x.Code,
+            Subject = x.Subject,
+            IsActive = x.IsActive
+        })];
+    }
+
+    public async Task<IActionResult> OnPostAsync(CancellationToken ct)
+    {
+        try
+        {
+            if (!ModelState.IsValid)
+            {
+                throw new Exception();
+            }
+
+            var cmd = new SaveBannerPositions.Command(
+                [..Input.Select(x => new SaveBannerPositions.Command.Row(
+                    x.ID,
+                    x.Code,
+                    x.Subject,
+                    x.IsActive
+                ))]
+            );
+
+            var response = await mediator.Send(cmd, ct);
+
+            TempData["SuccessMessage"] = $"ÀúÀå ¿Ï·á (Ãß°¡: {response.Inserted}, ¼öÁ¤: {response.Updated}, »èÁ¦: {response.Deleted})";
+        }
+        catch (Exception e)
+        {
+            TempData["ErrorMessages"] = e.Message;
+        }
+
+        return RedirectToPage("/Banner/Position");
+    }
+}

+ 8 - 0
Admin/Pages/Banner/_NavTabs.cshtml

@@ -0,0 +1,8 @@
+<ul class="nav nav-tabs">
+    <li class="nav-item">
+        <a class="nav-link @Html.IsActive("/Banner/List/Index")" asp-page="/Banner/List/Index">배너 목록</a>
+    </li>
+    <li class="nav-item">
+        <a class="nav-link @Html.IsActive("/Banner/Position")" asp-page="/Banner/Position">배너 위치</a>
+    </li>
+</ul>

+ 122 - 0
Admin/Pages/Channel/List/Edit.cshtml

@@ -0,0 +1,122 @@
+@page "{id:int}"
+@model Admin.Pages.Channel.List.EditModel
+@{
+    ViewData["Title"] = "채널 수정";
+}
+
+<div class="container">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <partial name="_StatusMessage" />
+
+    <div class="alert alert-success" role="alert">
+        채널 등록 시 YouTube API를 통해 자동 수집된 정보입니다.<br />
+        관리자가 직접 수정 시 잘못된 결과가 발생할 수 있습니다.
+    </div>
+
+    <form id="fAdminWrite" method="post" accept-charset="utf-8" autocomplete="off">
+        <input type="hidden" asp-for="Input.ID" />
+        <input type="hidden" asp-for="Input.MemberID" />
+
+        <div class="row mb-2">
+            <label class="col-sm-2 col-form-label">PK</label>
+            <div class="col-sm-10">
+                <input type="text" readonly class="form-control-plaintext" value="@Model.Input.ID" />
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label class="col-sm-2 col-form-label">회원(소유자)</label>
+            <div class="col-sm-10">
+                <input type="text" readonly class="form-control-plaintext" value="[@Model.Input.MemberID] @Model.MemberInfo" />
+            </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 type="text" asp-for="Input.Name" class="form-control" required maxlength="200" />
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Input.Handle" class="col-sm-2 col-form-label">핸들</label>
+            <div class="col-sm-10">
+                <div class="input-group">
+                    <span class="input-group-text">@@</span>
+                    <input type="text" asp-for="Input.Handle" class="form-control" maxlength="30" />
+                </div>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Input.YouTubeUrl" class="col-sm-2 col-form-label"><span class="text-danger">*</span> YouTube 주소</label>
+            <div class="col-sm-10">
+                <input type="url" asp-for="Input.YouTubeUrl" class="form-control" required maxlength="255" />
+                <div class="text-muted form-text">
+                    YouTube 채널 주소 (예: https://www.youtube.com/channel/UCxxxxxxxxxxxxxxxxxxxxxx)
+                </div>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Input.PlatformFeeRate" class="col-sm-2 col-form-label"><span class="text-danger">*</span> 수수료(%)</label>
+            <div class="col-sm-10">
+                <div class="row">
+                    <div class="col col-md-auto">
+                        <div class="input-group">
+                            <input type="number" asp-for="Input.PlatformFeeRate" class="form-control" required min="0" max="100" step="0.1" />
+                            <span class="input-group-text">%</span>
+                        </div>
+                    </div>
+                </div>
+                <span asp-validation-for="Input.PlatformFeeRate" class="text-danger"></span>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Input.IsVerified" class="col-sm-2 col-form-label">인증 여부</label>
+            <div class="col-sm-10 align-content-center">
+                <div class="form-check form-check-inline">
+                    <input type="checkbox" asp-for="Input.IsVerified" class="form-check-input" />
+                    <label class="form-check-label" asp-for="Input.IsVerified">인증합니다.</label>
+                </div>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Input.IsActive" class="col-sm-2 col-form-label">사용 여부</label>
+            <div class="col-sm-10 align-content-center">
+                <div class="form-check form-check-inline">
+                    <input type="checkbox" asp-for="Input.IsActive" class="form-check-input" />
+                    <label class="form-check-label" asp-for="Input.IsActive">사용합니다.</label>
+                </div>
+            </div>
+        </div>
+        @if (Model.Input.UpdatedAt is not null)
+        {
+            <div class="row mb-2">
+                <label class="col-sm-2 col-form-label">수정일시</label>
+                <div class="col-sm-10">
+                    <input type="text" class="form-control-plaintext" readonly value="@Model.Input.UpdatedAt" />
+                </div>
+            </div>
+        }
+        <div class="row mb-2">
+            <label class="col-sm-2 col-form-label">등록일시</label>
+            <div class="col-sm-10">
+                <input type="text" class="form-control-plaintext" readonly value="@Model.Input.CreatedAt" />
+            </div>
+        </div>
+
+        <hr />
+
+        <div class="d-grid gap-2 text-center d-md-block">
+            <button type="submit" class="btn btn-success">저장</button>
+            <a href="@(Model.ReturnUrl ?? "/Channel/List")" class="btn btn-secondary">취소</a>
+            <button type="submit"
+                    class="btn btn-danger"
+                    formaction="?handler=Delete"
+                    formnovalidate
+                    onclick="return confirm('삭제 하시겠습니까?');">
+                삭제
+            </button>
+        </div>
+
+        <br />
+    </form>
+</div>

+ 134 - 0
Admin/Pages/Channel/List/Edit.cshtml.cs

@@ -0,0 +1,134 @@
+using SharedKernel.Extensions;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Channel.List
+{
+    public class EditModel(IMediator mediator) : PageModel
+    {
+        [BindProperty]
+        public string? QueryString { get; set; }
+
+        public string? ReturnUrl { get; set; }
+
+        [BindProperty]
+        public InputModel Input { get; set; } = new();
+
+        public string? MemberInfo { get; set; }
+
+        public sealed class InputModel
+        {
+            [Required(ErrorMessage = "ID는 필수입니다.")]
+            public int ID { get; set; }
+
+            public int MemberID { get; set; }
+
+            [DisplayName("이름")]
+            [Required(ErrorMessage = "{0}은 필수입니다.")]
+            [StringLength(200, ErrorMessage = "{0}은 {1}자 이내로 입력하세요.")]
+            public string Name { get; set; } = default!;
+
+            [DisplayName("핸들")]
+            [StringLength(30, ErrorMessage = "{0}은 {1}자 이내로 입력하세요.")]
+            public string? Handle { get; set; }
+
+            [DisplayName("YouTube 주소")]
+            [Required(ErrorMessage = "{0}은 필수입니다.")]
+            [StringLength(255, ErrorMessage = "{0}은 {1}자 이내로 입력하세요.")]
+            public string YouTubeUrl { get; set; } = default!;
+
+            [DisplayName("수수료(%)")]
+            [Range(0, 100, ErrorMessage = "{0}은 {1}~{2} 사이여야 합니다.")]
+            public decimal PlatformFeeRate { get; set; }
+
+            [DisplayName("인증 여부")]
+            public bool IsVerified { get; set; }
+
+            [DisplayName("사용 여부")]
+            public bool IsActive { get; set; }
+
+            public string? UpdatedAt { get; set; }
+            public string CreatedAt { get; set; } = default!;
+        }
+
+        public async Task OnGetAsync(int id, CancellationToken ct)
+        {
+            var referer = Request.Headers.Referer.ToString();
+            ReturnUrl = string.IsNullOrEmpty(referer) ? null : referer;
+
+            var result = await mediator.Send(new GetChannel.Query(id), ct);
+            if (result is null)
+            {
+                return;
+            }
+
+            MemberInfo = $"{result.MemberEmail}, {result.MemberName ?? result.MemberSID ?? "-"}";
+
+            Input = new InputModel
+            {
+                ID = result.ID,
+                MemberID = result.MemberID,
+                Name = result.Name,
+                Handle = result.Handle ?? "-",
+                YouTubeUrl = result.YouTubeUrl,
+                PlatformFeeRate = result.PlatformFeeRate,
+                IsVerified = result.IsVerified,
+                IsActive = result.IsActive,
+                UpdatedAt = result.UpdatedAt.GetDateAt() ?? "-",
+                CreatedAt = result.CreatedAt.GetDateAt()
+            };
+
+            QueryString = Request.QueryString.ToString();
+        }
+
+        public async Task<IActionResult> OnPostAsync(CancellationToken ct)
+        {
+            try
+            {
+                if (!ModelState.IsValid)
+                {
+                    return Page();
+                }
+
+                await mediator.Send(new UpdateChannel.Command(
+                    Input.ID,
+                    Input.Name,
+                    Input.Handle,
+                    Input.YouTubeUrl,
+                    Input.PlatformFeeRate,
+                    Input.IsVerified,
+                    Input.IsActive
+                ), ct);
+
+                TempData["SuccessMessage"] = "채널이 수정되었습니다.";
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+            }
+
+            return Redirect($"/Channel/List/Edit/{Input.ID}{Request.QueryString}");
+        }
+
+        public async Task<IActionResult> OnPostDeleteAsync(CancellationToken ct)
+        {
+            try
+            {
+                await mediator.Send(new DeleteChannel.Command([Input.ID]), ct);
+
+                TempData["SuccessMessage"] = "채널이 삭제되었습니다.";
+
+                return RedirectToPage("/Channel/List/Index");
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+
+                return Redirect($"/Channel/List/Edit/{Input.ID}{Request.QueryString}");
+            }
+        }
+    }
+}

+ 154 - 0
Admin/Pages/Channel/List/Index.cshtml

@@ -0,0 +1,154 @@
+@page
+@model Admin.Pages.Channel.List.IndexModel
+@{
+    ViewData["Title"] = "채널 관리";
+}
+
+<div class="container-fluid">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <partial name="_StatusMessage" />
+
+    <div class="row g-2 align-items-end">
+        <div class="col-6 col-sm-auto">
+            <select name="search" id="search" class="form-select" form="fAdminSearch">
+                <option value="1" selected="@(Model.Query.Search == 1)">채널 이름</option>
+                <option value="2" selected="@(Model.Query.Search == 2)">채널 SID</option>
+                <option value="3" selected="@(Model.Query.Search == 3)">핸들</option>
+                <option value="4" selected="@(Model.Query.Search == 4)">YouTube URL</option>
+                <option value="5" selected="@(Model.Query.Search == 5)">회원 ID</option>
+                <option value="6" selected="@(Model.Query.Search == 6)">회원 이메일</option>
+                <option value="7" selected="@(Model.Query.Search == 7)">회원 이름</option>
+            </select>
+        </div>
+        <div class="col-6 col-sm-auto">
+            <select name="isVerified" id="isVerified" class="form-select" form="fAdminSearch">
+                <option value="">인증 전체</option>
+                <option value="false" selected="@(Model.Query.IsVerified.HasValue && Model.Query.IsVerified == false)">미인증</option>
+                <option value="true" selected="@(Model.Query.IsVerified.HasValue && Model.Query.IsVerified == true)">인증</option>
+            </select>
+        </div>
+        <div class="col-12 col-sm col-md col-lg-auto">
+            <input type="text" name="keyword" id="keyword" class="form-control" maxlength="100" value="@Model.Query.Keyword" placeholder="검색어" form="fAdminSearch" />
+        </div>
+        <div class="col-12 col-md-12 col-lg-auto">
+            <div class="input-group">
+                <input type="date" name="startAt" id="startAt" class="form-control" value="@Model.Query.StartAt" form="fAdminSearch" />
+                <span class="input-group-text">~</span>
+                <input type="date" name="endAt" id="endAt" class="form-control" value="@Model.Query.EndAt" form="fAdminSearch" />
+            </div>
+        </div>
+        <div class="col-12 col-lg-auto text-center">
+            <button type="submit" id="btnSearch" class="btn btn-primary" form="fAdminSearch">검색</button>
+        </div>
+    </div>
+
+    <hr />
+
+    <div class="row g-2 align-items-end">
+        <div class="col">
+            Total : @Model?.Total.ToString("N0")
+        </div>
+        <div class="col-auto">
+            <select name="perPage" id="perPage" class="form-select w-auto d-inline-block" form="fAdminSearch">
+                <option value="10" selected="@(Model.Query.PerPage == 10)">10</option>
+                <option value="20" selected="@(Model.Query.PerPage == 20)">20</option>
+                <option value="50" selected="@(Model.Query.PerPage == 50)">50</option>
+                <option value="100" selected="@(Model.Query.PerPage == 100)">100</option>
+            </select>
+        </div>
+        <div class="col-auto">
+            <button type="button" id="btnListDelete" class="btn btn-danger" form="fAdminList" disabled>삭제</button>
+            <a class="btn btn-success" asp-page="/Channel/List/Write">추가</a>
+        </div>
+    </div>
+
+    <div class="table-responsive">
+        <table class="table table-striped table-bordered table-hover mt-3">
+            <thead>
+                <tr>
+                    <th>
+                        <div class="form-check form-check-inline">
+                            <input type="checkbox" id="checkedAll" class="form-check-input" value="1" form="fAdminList" />
+                            <label for="checkedAll">ID</label>
+                        </div>
+                    </th>
+                    <th>SID</th>
+                    <th>이름</th>
+                    <th>핸들</th>
+                    <th>소유자</th>
+                    <th>인증</th>
+                    <th>사용</th>
+                    <th>등록일시</th>
+                </tr>
+            </thead>
+            <tbody>
+                @if (Model.List.Count == 0)
+                {
+                    <tr>
+                        <td colspan="9">No data.</td>
+                    </tr>
+                }
+                else
+                {
+                    @foreach (var row in Model.List)
+                    {
+                        <tr>
+                            <td>
+                                <div class="form-check form-check-inline">
+                                    <input type="checkbox" name="ids[]" id="ids_@row.ID" class="form-check-input list-check-box" value="@row.ID" form="fAdminList" />
+                                    <label for="ids_@row.ID">@row.ID</label>
+                                </div>
+                            </td>
+                            <td>
+                                @if (Model.LiveChannelIds.Contains(row.SID))
+                                {
+                                    <span class="badge bg-danger me-1" style="font-size:.65em;">LIVE</span>
+                                }
+                                <a href="/Channel/List/View/@row.ID">@row.SID</a>
+                            </td>
+                            <td>
+                                <a href="/Channel/List/Edit/@row.ID">@row.Name</a>
+                            </td>
+                            <td>@row.Handle</td>
+                            <td>[@row.MemberID] @row.MemberName</td>
+                            <td>@row.IsVerified</td>
+                            <td>@row.IsActive</td>
+                            <td>@row.CreatedAt</td>
+                        </tr>
+                    }
+                }
+            </tbody>
+        </table>
+
+        <partial name="_Pagination" model="Model.Pagination" />
+    </div>
+</div>
+
+<form id="fAdminSearch" method="get" accept-charset="utf-8">
+    <input type="hidden" name="pageNum" value="1" />
+    <input type="hidden" name="perPage" value="@Model.Query.PerPage" />
+</form>
+
+<form id="fAdminList" method="post" accept-charset="utf-8">
+    @Html.AntiForgeryToken()
+    <input type="hidden" name="pageNum" value="@Model.Query.PageNum" />
+    <input type="hidden" name="perPage" value="@Model.Query.PerPage" />
+    <input type="hidden" name="search" value="@Model.Query.Search" />
+    <input type="hidden" name="keyword" value="@Model.Query.Keyword" />
+    <input type="hidden" name="startAt" value="@Model.Query.StartAt" />
+    <input type="hidden" name="endAt" value="@Model.Query.EndAt" />
+    <input type="hidden" name="isVerified" value="@Model.Query.IsVerified" />
+</form>
+
+@section Scripts {
+    <script>
+        let searchForm = document.getElementById("fAdminSearch");
+
+        $(document).on("change", "#perPage", function () {
+            searchForm.elements["pageNum"].value = "1";
+            searchForm.submit();
+        });
+    </script>
+}

+ 116 - 0
Admin/Pages/Channel/List/Index.cshtml.cs

@@ -0,0 +1,116 @@
+using SharedKernel.Helpers;
+using SharedKernel.Extensions;
+using Application.Abstractions.YouTube;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Channel.List
+{
+    public class IndexModel(IMediator mediator, IYouTubeLiveStateStore liveStateStore) : PageModel
+    {
+        [BindProperty(SupportsGet = true)]
+        public QueryParams Query { get; set; } = new();
+
+        public sealed class QueryParams
+        {
+            public int? Search { get; set; }
+            public string? Keyword { get; set; }
+            public string? StartAt { get; set; }
+            public string? EndAt { get; set; }
+            public bool? IsVerified { get; set; }
+
+            [Range(1, int.MaxValue)]
+            [DisplayName("페이지 번호")]
+            public int PageNum { get; set; } = 1;
+
+            [Range(1, 100)]
+            [DisplayName("페이지 목록 수")]
+            public ushort PerPage { get; set; } = 20;
+        }
+
+        public int Total { get; set; } = 0;
+
+        public List<(
+            int Num,
+            int ID,
+            string SID,
+            string Name,
+            string? Handle,
+            string YouTubeUrl,
+            decimal PlatformFeeRate,
+            char IsVerified,
+            char IsActive,
+            int MemberID,
+            string? MemberName,
+            string? MemberEmail,
+            string? UpdatedAt,
+            string CreatedAt
+        )> List { get; set; } = [];
+
+        public Pagination? Pagination { get; set; }
+
+        /// <summary>현재 라이브 중인 채널 SID(=YouTube 채널 ID) 집합</summary>
+        public HashSet<string> LiveChannelIds { get; set; } = [];
+
+        public async Task OnGetAsync(CancellationToken ct)
+        {
+            if (!ModelState.IsValid)
+            {
+                return;
+            }
+
+            var result = await mediator.Send(new SearchChannels.Query(
+                Query.Search,
+                Query.Keyword,
+                Query.IsVerified,
+                Query.StartAt,
+                Query.EndAt,
+                Query.PageNum,
+                Query.PerPage
+            ), ct);
+
+            Total = result.Total;
+            List = [..result.List.Select(c => (
+                c.Num,
+                c.ID,
+                c.SID,
+                c.Name,
+                c.Handle,
+                c.YouTubeUrl,
+                c.PlatformFeeRate,
+                c.IsVerified ? 'Y' : 'N',
+                c.IsActive ? 'Y' : 'N',
+                c.MemberID,
+                c.MemberName ?? "-",
+                c.MemberEmail ?? "-",
+                c.UpdatedAt.GetDateAt() ?? "-",
+                c.CreatedAt.GetDateAt()
+            ))];
+
+            Pagination = new Pagination(result.Total, Query.PageNum, Query.PerPage);
+
+            // Redis에서 현재 라이브 중인 채널 ID 조회 (API 호출 없음)
+            var allLive = await liveStateStore.GetAllLiveAsync();
+            LiveChannelIds = [..allLive.Select(l => l.ChannelId)];
+        }
+
+        public async Task<IActionResult> OnPostDeleteAsync(int[] ids, CancellationToken ct)
+        {
+            try
+            {
+                await mediator.Send(new DeleteChannel.Command(ids), ct);
+
+                TempData["SuccessMessage"] = $"{ids.Length}건이 삭제되었습니다.";
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+            }
+
+            return RedirectToPage("/Channel/List/Index", Query);
+        }
+    }
+}

+ 267 - 0
Admin/Pages/Channel/List/View.cshtml

@@ -0,0 +1,267 @@
+@page "{id:int}"
+@model Admin.Pages.Channel.List.ViewModel
+@{
+    ViewData["Title"] = "채널 정보";
+}
+
+<div class="container">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <partial name="_StatusMessage" />
+
+    @* ── DB 채널 정보 ───────────────────────────────────────────── *@
+    <h5 class="mt-3 mb-2"><i class="bi bi-database"></i> 채널 기본 정보</h5>
+    <div class="overflow-hidden border rounded mb-4">
+        <div class="row g-0 border-bottom">
+            <div class="col-12 col-md-2 fw-bold p-2 bg-light">PK</div>
+            <div class="col-12 col-md-10 p-2">@Model.ID</div>
+        </div>
+        <div class="row g-0 border-bottom">
+            <div class="col-12 col-md-2 fw-bold p-2 bg-light">회원(소유자)</div>
+            <div class="col-12 col-md-10 p-2">@Model.MemberInfo</div>
+        </div>
+        <div class="row g-0 border-bottom">
+            <div class="col-12 col-md-2 fw-bold p-2 bg-light">SID</div>
+            <div class="col-12 col-md-10 p-2"><code>@Model.SID</code></div>
+        </div>
+        <div class="row g-0 border-bottom">
+            <div class="col-12 col-md-2 fw-bold p-2 bg-light">이름</div>
+            <div class="col-12 col-md-10 p-2">@Model.Name</div>
+        </div>
+        <div class="row g-0 border-bottom">
+            <div class="col-12 col-md-2 fw-bold p-2 bg-light">핸들</div>
+            <div class="col-12 col-md-10 p-2">@(Model.Handle ?? "-")</div>
+        </div>
+        <div class="row g-0 border-bottom">
+            <div class="col-12 col-md-2 fw-bold p-2 bg-light">YouTube 주소</div>
+            <div class="col-12 col-md-10 p-2">
+                <a href="@Model.YouTubeUrl" target="_blank" rel="external">@Model.YouTubeUrl</a>
+            </div>
+        </div>
+        <div class="row g-0 border-bottom">
+            <div class="col-12 col-md-2 fw-bold p-2 bg-light">수수료(%)</div>
+            <div class="col-12 col-md-10 p-2">@Model.PlatformFeeRate%</div>
+        </div>
+        <div class="row g-0 border-bottom">
+            <div class="col-12 col-md-2 fw-bold p-2 bg-light">인증 여부</div>
+            <div class="col-12 col-md-10 p-2">
+                @if (Model.IsVerified)
+                {
+                    <span class="badge bg-success">인증됨</span>
+                }
+                else
+                {
+                    <span class="badge bg-secondary">미인증</span>
+                }
+            </div>
+        </div>
+        <div class="row g-0 border-bottom">
+            <div class="col-12 col-md-2 fw-bold p-2 bg-light">사용 여부</div>
+            <div class="col-12 col-md-10 p-2">
+                @if (Model.IsActive)
+                {
+                    <span class="badge bg-primary">활성</span>
+                }
+                else
+                {
+                    <span class="badge bg-danger">비활성</span>
+                }
+            </div>
+        </div>
+        <div class="row g-0 border-bottom">
+            <div class="col-12 col-md-2 fw-bold p-2 bg-light">수정일시</div>
+            <div class="col-12 col-md-10 p-2">@(Model.UpdatedAt ?? "-")</div>
+        </div>
+        <div class="row g-0">
+            <div class="col-12 col-md-2 fw-bold p-2 bg-light">등록일시</div>
+            <div class="col-12 col-md-10 p-2">@Model.CreatedAt</div>
+        </div>
+    </div>
+
+    @* ── YouTube API 채널 정보 ───────────────────────────────────── *@
+    <h5 class="mt-4 mb-2"><i class="bi bi-youtube"></i> YouTube 채널 상세 정보</h5>
+
+    @if (Model.YouTubeApiFailed)
+    {
+        <div class="alert alert-warning mt-3">
+            <i class="bi bi-exclamation-triangle"></i> @Model.YouTubeApiError
+        </div>
+    }
+    else if (Model.YouTubeChannel is not null)
+    {
+        var yt = Model.YouTubeChannel;
+
+        <div class="border rounded mb-4">
+            @* 채널 프로필 헤더 *@
+            <div class="d-flex align-items-center p-3 bg-light border-bottom">
+                <img src="@yt.ThumbnailUrl" alt="@yt.Title" class="rounded-circle me-3"
+                     style="width:64px; height:64px; object-fit:cover;" />
+                <div>
+                    <h5 class="mb-0">@yt.Title</h5>
+                    @if (yt.CustomUrl is not null)
+                    {
+                        <a href="https://youtube.com/@@(yt.CustomUrl.TrimStart('@@'))" target="_blank"
+                           class="text-muted small">
+                            @@(yt.CustomUrl.TrimStart('@@'))
+                        </a>
+                    }
+                </div>
+            </div>
+
+            @* 통계 카드 *@
+            <div class="row g-0 text-center border-bottom">
+                <div class="col-4 p-3 border-end">
+                    <div class="fs-4 fw-bold">@FormatNumber(yt.SubscriberCount)</div>
+                    <div class="text-muted small">구독자</div>
+                </div>
+                <div class="col-4 p-3 border-end">
+                    <div class="fs-4 fw-bold">@FormatNumber(yt.VideoCount)</div>
+                    <div class="text-muted small">동영상</div>
+                </div>
+                <div class="col-4 p-3">
+                    <div class="fs-4 fw-bold">@FormatNumber(yt.ViewCount)</div>
+                    <div class="text-muted small">총 조회수</div>
+                </div>
+            </div>
+
+            @* 상세 정보 테이블 *@
+            <div class="row g-0 border-bottom">
+                <div class="col-12 col-md-2 fw-bold p-2 bg-light">채널 ID</div>
+                <div class="col-12 col-md-10 p-2">
+                    <code>@yt.ChannelId</code>
+                    <a href="https://youtube.com/channel/@yt.ChannelId" target="_blank" class="ms-2 small">
+                        <i class="bi bi-box-arrow-up-right"></i>
+                    </a>
+                </div>
+            </div>
+            <div class="row g-0 border-bottom">
+                <div class="col-12 col-md-2 fw-bold p-2 bg-light">채널 이름</div>
+                <div class="col-12 col-md-10 p-2">@yt.Title</div>
+            </div>
+            <div class="row g-0 border-bottom">
+                <div class="col-12 col-md-2 fw-bold p-2 bg-light">커스텀 URL</div>
+                <div class="col-12 col-md-10 p-2">@(yt.CustomUrl ?? "-")</div>
+            </div>
+            <div class="row g-0 border-bottom">
+                <div class="col-12 col-md-2 fw-bold p-2 bg-light">구독자 수</div>
+                <div class="col-12 col-md-10 p-2">@yt.SubscriberCount.ToString("N0")</div>
+            </div>
+            <div class="row g-0 border-bottom">
+                <div class="col-12 col-md-2 fw-bold p-2 bg-light">동영상 수</div>
+                <div class="col-12 col-md-10 p-2">@yt.VideoCount.ToString("N0")</div>
+            </div>
+            <div class="row g-0 border-bottom">
+                <div class="col-12 col-md-2 fw-bold p-2 bg-light">총 조회수</div>
+                <div class="col-12 col-md-10 p-2">@yt.ViewCount.ToString("N0")</div>
+            </div>
+            <div class="row g-0 border-bottom">
+                <div class="col-12 col-md-2 fw-bold p-2 bg-light">썸네일</div>
+                <div class="col-12 col-md-10 p-2">
+                    <img src="@yt.ThumbnailUrl" alt="thumbnail" class="rounded" style="max-width:120px;" />
+                </div>
+            </div>
+            <div class="row g-0">
+                <div class="col-12 col-md-2 fw-bold p-2 bg-light">설명</div>
+                <div class="col-12 col-md-10 p-2">
+                    @if (!string.IsNullOrWhiteSpace(yt.Description))
+                    {
+                        <div style="max-height:200px; overflow-y:auto; white-space:pre-wrap;">@yt.Description</div>
+                    }
+                    else
+                    {
+                        <span class="text-muted">-</span>
+                    }
+                </div>
+            </div>
+        </div>
+    }
+
+    @* ── 생방송 방송 상태 (PubSub 기반 — Redis 조회, API 0 unit) ──── *@
+    <h5 class="mt-4 mb-2"><i class="bi bi-broadcast"></i> 생방송 상태</h5>
+
+    @if (Model.LiveStream is not null)
+    {
+        var live = Model.LiveStream;
+
+        <div class="border rounded mb-4 border-danger">
+            <div class="d-flex align-items-center p-3 bg-danger bg-opacity-10 border-bottom">
+                <span class="badge bg-danger me-2 fs-6">LIVE</span>
+                <h6 class="mb-0">@live.Title</h6>
+            </div>
+            <div class="row g-0 border-bottom">
+                <div class="col-12 col-md-2 fw-bold p-2 bg-light">Video ID</div>
+                <div class="col-12 col-md-10 p-2">
+                    <code>@live.VideoId</code>
+                    <a href="https://youtube.com/watch?v=@live.VideoId" target="_blank" class="ms-2 small">
+                        <i class="bi bi-box-arrow-up-right"></i>
+                    </a>
+                </div>
+            </div>
+            <div class="row g-0 border-bottom">
+                <div class="col-12 col-md-2 fw-bold p-2 bg-light">Live Chat ID</div>
+                <div class="col-12 col-md-10 p-2"><code>@(live.ActiveLiveChatId ?? "-")</code></div>
+            </div>
+            <div class="row g-0 border-bottom">
+                <div class="col-12 col-md-2 fw-bold p-2 bg-light">방송 시작</div>
+                <div class="col-12 col-md-10 p-2">@(live.ActualStartTime?.ToString("yyyy-MM-dd HH:mm:ss") ?? "-")</div>
+            </div>
+            <div class="row g-0">
+                <div class="col-12 col-md-2 fw-bold p-2 bg-light">상태</div>
+                <div class="col-12 col-md-10 p-2">
+                    @if (live.IsLive)
+                    {
+                        <span class="badge bg-danger">생방송 중</span>
+                    }
+                    else if (live.IsUpcoming)
+                    {
+                        <span class="badge bg-warning text-dark">예정됨 — @(live.ScheduledStartTime?.ToString("yyyy-MM-dd HH:mm") ?? "")</span>
+                    }
+                </div>
+            </div>
+        </div>
+    }
+    else
+    {
+        <div class="border rounded p-3 mb-4 text-muted bg-light">
+            방송 종료
+        </div>
+    }
+
+    <div class="d-grid gap-2 text-center d-md-block mt-3">
+        <a href="/Channel/List/Edit/@Model.ID" class="btn btn-info text-white">수정</a>
+        <a href="/Channel/List" class="btn btn-secondary">목록</a>
+    </div>
+
+    <br />
+    <br />
+</div>
+
+@section Styles {
+    <style>
+        @@media (min-width: 768px) {
+            .border-end-md {
+                border-right: 1px solid var(--bs-border-color) !important;
+            }
+        }
+    </style>
+}
+
+@functions {
+    static string FormatNumber(long value)
+    {
+        if (value >= 100_000_000)
+        {
+            return $"{value / 100_000_000.0:0.#}억";
+        }
+        if (value >= 10_000)
+        {
+            return $"{value / 10_000.0:0.#}만";
+        }
+        if (value >= 1_000)
+        {
+            return $"{value / 1_000.0:0.#}천";
+        }
+        return value.ToString("N0");
+    }
+}

+ 79 - 0
Admin/Pages/Channel/List/View.cshtml.cs

@@ -0,0 +1,79 @@
+using Application.Abstractions.YouTube;
+using SharedKernel.Extensions;
+using MediatR;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace Admin.Pages.Channel.List
+{
+    public class ViewModel(IMediator mediator, IYouTubeApiService youTubeApi, IYouTubeLiveStateStore liveStateStore) : PageModel
+    {
+        // ── DB 데이터 ────────────────────────────────────────────────
+        public int ID { get; set; }
+        public int MemberID { get; set; }
+        public string? MemberInfo { get; set; }
+        public string SID { get; set; } = default!;
+        public string Name { get; set; } = default!;
+        public string? Handle { get; set; }
+        public string YouTubeUrl { get; set; } = default!;
+        public decimal PlatformFeeRate { get; set; }
+        public bool IsVerified { get; set; }
+        public bool IsActive { get; set; }
+        public string? UpdatedAt { get; set; }
+        public string CreatedAt { get; set; } = default!;
+
+        // ── YouTube API 데이터 ───────────────────────────────────────
+        public YouTubeChannelInfo? YouTubeChannel { get; set; }
+        public bool YouTubeApiFailed { get; set; }
+        public string? YouTubeApiError { get; set; }
+
+        // ── 라이브 상태 (PubSub 기반 — Redis 조회만, API 호출 없음) ──
+        public YouTubeLiveStreamInfo? LiveStream { get; set; }
+
+        public async Task OnGetAsync(int id, CancellationToken ct)
+        {
+            var result = await mediator.Send(new GetChannel.Query(id), ct);
+            if (result is null)
+            {
+                return;
+            }
+
+            ID = result.ID;
+            MemberID = result.MemberID;
+            MemberInfo = $"[{result.MemberID}] {result.MemberEmail}, {result.MemberName ?? result.MemberSID ?? "-"}";
+            SID = result.SID;
+            Name = result.Name;
+            Handle = result.Handle;
+            YouTubeUrl = result.YouTubeUrl;
+            PlatformFeeRate = result.PlatformFeeRate;
+            IsVerified = result.IsVerified;
+            IsActive = result.IsActive;
+            UpdatedAt = result.UpdatedAt.GetDateAt();
+            CreatedAt = result.CreatedAt.GetDateAt();
+
+            // SID = YouTube 채널 ID → 채널 정보 조회 (1 unit)
+            await FetchYouTubeChannelAsync(result.SID, ct);
+
+            // 라이브 상태는 Redis에서만 조회 (API 호출 없음, 0 unit)
+            LiveStream = await liveStateStore.GetLiveAsync(result.SID);
+        }
+
+        private async Task FetchYouTubeChannelAsync(string channelId, CancellationToken ct)
+        {
+            try
+            {
+                YouTubeChannel = await youTubeApi.GetChannelByIdAsync(channelId, ct);
+
+                if (YouTubeChannel is null)
+                {
+                    YouTubeApiFailed = true;
+                    YouTubeApiError = $"채널 정보를 찾을 수 없습니다. 조회한 SID: \"{channelId}\" — DB Config의 YouTube API Key 설정을 확인하세요.";
+                }
+            }
+            catch (Exception ex)
+            {
+                YouTubeApiFailed = true;
+                YouTubeApiError = $"YouTube API 조회 실패: {ex.Message}";
+            }
+        }
+    }
+}

+ 198 - 0
Admin/Pages/Channel/List/Write.cshtml

@@ -0,0 +1,198 @@
+@page
+@model Admin.Pages.Channel.List.WriteModel
+@{
+    ViewData["Title"] = "채널 등록";
+}
+
+<div class="container">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <partial name="_StatusMessage" />
+
+    <div class="alert alert-success" role="alert">
+        채널 등록 시 YouTube API를 통해 자동 수집된 정보입니다.<br />
+        관리자가 직접 등록 시 잘못된 결과가 발생할 수 있습니다.
+    </div>
+
+    <form id="fAdminWrite" method="post" accept-charset="utf-8" autocomplete="off">
+        <div class="row mb-2">
+            <label class="col-sm-2 col-form-label"><span class="text-danger">*</span> 회원(소유자)</label>
+            <div class="col-sm-10">
+                <input type="hidden" asp-for="Input.MemberID" id="memberIdHidden" />
+                <div id="memberSelected" class="mb-1" style="display:none;">
+                    <span class="badge bg-primary fs-6 fw-normal" id="memberBadge">
+                        <span id="memberBadgeText"></span>
+                        <button type="button" class="btn-close btn-close-white ms-2" aria-label="제거" id="memberRemoveBtn" style="font-size:0.6em;vertical-align:middle;"></button>
+                    </span>
+                </div>
+                <div class="position-relative" id="memberSearchWrap">
+                    <div class="input-group">
+                        <span class="input-group-text"><i class="bi bi-search"></i></span>
+                        <input type="text" class="form-control" id="memberSearchInput" placeholder="회원 ID, SID, 이메일로 검색" autocomplete="off" />
+                    </div>
+                    <div class="list-group position-absolute w-100 shadow" id="memberSearchResults" style="z-index:1050;display:none;max-height:300px;overflow-y:auto;"></div>
+                </div>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Input.SID" class="col-sm-2 col-form-label"><span class="text-danger">*</span> SID</label>
+            <div class="col-sm-10">
+                <input type="text" asp-for="Input.SID" class="form-control" required minlength="24" maxlength="24" placeholder="중복 시 등록이 불가합니다. 24자" />
+                <div class="text-muted form-text">
+                    YouTube 채널 고유 ID (예: UCxxxxxxxxxxxxxxxxxxxxxx)
+                </div>
+            </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 type="text" asp-for="Input.Name" class="form-control" required maxlength="200" />
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Input.Handle" class="col-sm-2 col-form-label">핸들</label>
+            <div class="col-sm-10">
+                <div class="input-group">
+                    <span class="input-group-text">@@</span>
+                    <input type="text" asp-for="Input.Handle" class="form-control" maxlength="30" />
+                </div>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Input.YouTubeUrl" class="col-sm-2 col-form-label"><span class="text-danger">*</span> YouTube 주소</label>
+            <div class="col-sm-10">
+                <input type="url" asp-for="Input.YouTubeUrl" class="form-control" required maxlength="255" />
+                <div class="text-muted form-text">
+                    YouTube 채널 주소 (예: https://www.youtube.com/channel/UCxxxxxxxxxxxxxxxxxxxxxx)
+                </div>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Input.PlatformFeeRate" class="col-sm-2 col-form-label"><span class="text-danger">*</span> 수수료(%)</label>
+            <div class="col-sm-10">
+                <div class="row">
+                    <div class="col col-md-auto">
+                        <div class="input-group">
+                            <input type="number" asp-for="Input.PlatformFeeRate" class="form-control" required min="0" max="100" step="0.1" />
+                            <span class="input-group-text">%</span>
+                        </div>
+                    </div>
+                </div>
+                <span asp-validation-for="Input.PlatformFeeRate" class="text-danger"></span>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Input.IsVerified" class="col-sm-2 col-form-label">인증 여부</label>
+            <div class="col-sm-10 align-content-center">
+                <div class="form-check form-check-inline">
+                    <input type="checkbox" asp-for="Input.IsVerified" class="form-check-input" />
+                    <label class="form-check-label" asp-for="Input.IsVerified">인증합니다.</label>
+                </div>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Input.IsActive" class="col-sm-2 col-form-label">사용 여부</label>
+            <div class="col-sm-10 align-content-center">
+                <div class="form-check form-check-inline">
+                    <input type="checkbox" asp-for="Input.IsActive" class="form-check-input" />
+                    <label class="form-check-label" asp-for="Input.IsActive">사용합니다.</label>
+                </div>
+            </div>
+        </div>
+
+        <hr />
+
+        <div class="d-grid gap-2 text-center d-md-block">
+            <button type="submit" class="btn btn-success">저장</button>
+            <a href="/Channel/List" class="btn btn-secondary">취소</a>
+        </div>
+
+        <br />
+    </form>
+</div>
+
+@section Scripts {
+<script>
+    $(function () {
+        let timer = null;
+        const $input = $('#memberSearchInput');
+        const $results = $('#memberSearchResults');
+        const $hidden = $('#memberIdHidden');
+        const $selected = $('#memberSelected');
+        const $badgeText = $('#memberBadgeText');
+        const $searchWrap = $('#memberSearchWrap');
+
+        function showSelected(id, email, name, sid) {
+            const display = id + ' (' + email + ')' + (name ? ' ' + name : '') + (sid ? ' ' + sid : '');
+            $badgeText.text(display);
+            $hidden.val(id);
+            $selected.show();
+            $searchWrap.hide();
+            $results.hide();
+        }
+
+        function clearSelected() {
+            $hidden.val('');
+            $selected.hide();
+            $searchWrap.show();
+            $input.val('').focus();
+        }
+
+        $('#memberRemoveBtn').on('click', function () {
+            clearSelected();
+        });
+
+        $input.on('input', function () {
+            clearTimeout(timer);
+            const val = $(this).val().trim();
+            if (val.length < 1) {
+                $results.hide().empty();
+                return;
+            }
+            timer = setTimeout(function () {
+                $.getJSON('/Channel/List/Write?handler=SearchMember&keyword=' + encodeURIComponent(val), function (data) {
+                    $results.empty();
+                    if (data.length === 0) {
+                        $results.append('<div class="list-group-item text-muted">검색 결과가 없습니다.</div>');
+                    } else {
+                        $.each(data, function (i, m) {
+                            const text = m.id + ' (' + m.email + ')' + (m.name ? ' ' + m.name : '') + (m.sid ? ' ' + m.sid : '');
+                            $results.append(
+                                $('<a href="#" class="list-group-item list-group-item-action"></a>')
+                                    .text(text)
+                                    .on('click', function (e) {
+                                        e.preventDefault();
+                                        showSelected(m.id, m.email, m.name, m.sid);
+                                    })
+                            );
+                        });
+                    }
+                    $results.show();
+                });
+            }, 300);
+        });
+
+        $(document).on('click', function (e) {
+            if (!$(e.target).closest('#memberSearchWrap').length) {
+                $results.hide();
+            }
+        });
+
+        $input.on('focus', function () {
+            if ($results.children().length > 0) {
+                $results.show();
+            }
+        });
+
+        // 폼 제출 시 회원 선택 여부 확인
+        $('#fAdminWrite').on('submit', function (e) {
+            if (!$hidden.val() || $hidden.val() === '0') {
+                e.preventDefault();
+                alert('회원(소유자)을 선택해주세요.');
+                $input.focus();
+            }
+        });
+    });
+</script>
+}

+ 126 - 0
Admin/Pages/Channel/List/Write.cshtml.cs

@@ -0,0 +1,126 @@
+using Application.Abstractions.Data;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.EntityFrameworkCore;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Channel.List
+{
+    public class WriteModel(IMediator mediator, IAppDbContext db) : PageModel
+    {
+        public string QueryString { get; private set; } = "";
+
+        [BindProperty]
+        public InputModel Input { get; set; } = new();
+
+        public sealed class InputModel
+        {
+            [DisplayName("회원 ID")]
+            [Required(ErrorMessage = "{0}은 필수입니다.")]
+            public int MemberID { get; set; }
+
+            [DisplayName("SID")]
+            [Required(ErrorMessage = "{0}은 필수입니다.")]
+            [StringLength(24, MinimumLength = 24, ErrorMessage = "{0}은 {1}자여야 합니다.")]
+            public string SID { get; set; } = default!;
+
+            [DisplayName("이름")]
+            [Required(ErrorMessage = "{0}은 필수입니다.")]
+            [StringLength(200, ErrorMessage = "{0}은 {1}자 이내로 입력하세요.")]
+            public string Name { get; set; } = default!;
+
+            [DisplayName("핸들")]
+            [StringLength(30, ErrorMessage = "{0}은 {1}자 이내로 입력하세요.")]
+            public string? Handle { get; set; }
+
+            [DisplayName("YouTube 주소")]
+            [Required(ErrorMessage = "{0}은 필수입니다.")]
+            [StringLength(255, ErrorMessage = "{0}은 {1}자 이내로 입력하세요.")]
+            public string YouTubeUrl { get; set; } = default!;
+
+            [DisplayName("수수료(%)")]
+            [Range(0, 100, ErrorMessage = "{0}은 {1}~{2} 사이여야 합니다.")]
+            public decimal PlatformFeeRate { get; set; } = 0;
+
+            [DisplayName("인증 여부")]
+            public bool IsVerified { get; set; } = false;
+
+            [DisplayName("사용 여부")]
+            public bool IsActive { get; set; } = false;
+        }
+
+        public Task OnGetAsync(CancellationToken _)
+        {
+            QueryString = HttpContext.Request.QueryString.HasValue ? HttpContext.Request.QueryString.Value!.TrimStart('?') : "";
+
+            return Task.CompletedTask;
+        }
+
+        public async Task<IActionResult> OnGetSearchMemberAsync(string keyword, CancellationToken ct)
+        {
+            if (string.IsNullOrWhiteSpace(keyword) || keyword.Trim().Length < 1)
+            {
+                return new JsonResult(Array.Empty<object>());
+            }
+
+            var kw = keyword.Trim();
+
+            var isNumeric = int.TryParse(kw, out var numericId);
+
+            var list = await db.Member
+                .AsNoTracking()
+                .Where(x =>
+                    (isNumeric && x.ID == numericId) ||
+                    x.SID.Contains(kw) ||
+                    x.Email.Contains(kw) ||
+                    (x.Name != null && x.Name.Contains(kw))
+                )
+                .OrderByDescending(x => x.ID)
+                .Take(10)
+                .Select(x => new
+                {
+                    id = x.ID,
+                    email = x.Email,
+                    name = x.Name,
+                    sid = x.SID
+                })
+                .ToListAsync(ct);
+
+            return new JsonResult(list);
+        }
+
+        public async Task<IActionResult> OnPostAsync(CancellationToken ct)
+        {
+            try
+            {
+                if (!ModelState.IsValid)
+                {
+                    throw new Exception("유효성 검사에 실패했습니다.");
+                }
+
+                await mediator.Send(new CreateChannel.Command(
+                    Input.MemberID,
+                    Input.SID,
+                    Input.Name,
+                    Input.Handle,
+                    Input.YouTubeUrl,
+                    Input.PlatformFeeRate,
+                    Input.IsVerified,
+                    Input.IsActive
+                ), ct);
+
+                TempData["SuccessMessage"] = "채널이 등록되었습니다.";
+
+                return RedirectToPage("/Channel/List/Index");
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+
+                return Page();
+            }
+        }
+    }
+}

+ 213 - 0
Admin/Pages/Config/Basic/Images.cshtml

@@ -0,0 +1,213 @@
+@page
+@model Admin.Pages.Config.Basic.ImagesModel
+@{
+    ViewData["Title"] = "이미지 설정";
+}
+
+<div class="container">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+    <partial name="_navTabs" />
+
+    <div asp-validation-summary="ModelOnly" class="text-danger"></div>
+    <partial name="_StatusMessage" />
+
+    <form name="f_admin_write" id="fAdminWrite" class="mt-2" method="post" autocomplete="off" accept-charset="UTF-8" enctype="multipart/form-data">
+
+        <!-- Favicon -->
+        <div class="row mb-2 align-items-center">
+            <label asp-for="Input.Images.FaviconFile" class="col-sm-2 col-form-label">Favicon</label>
+            <div class="col-sm-10">
+                @if (string.IsNullOrEmpty(Model.Input.Images.FaviconPath))
+                {
+                    <input asp-for="Input.Images.FaviconFile" class="form-control" accept=".ico,image/x-icon" />
+                    <span asp-validation-for="Input.Images.FaviconFile" class="text-danger"></span>
+                    <div class="form-text text-muted">브라우저 탭/즐겨찾기에 표시됩니다. 권장: .ico, 32x32 또는 48x48</div>
+                }
+                else
+                {
+                    <img src="@Model.Input.Images.FaviconPath" alt="favicon" class="img-fluid rounded" />
+                    <div class="form-check mt-2">
+                        <input class="form-check-input" type="checkbox" value="1" name="delete_favicon" id="deleteFavicon" />
+                        <label class="form-check-label" for="deleteFavicon">삭제</label>
+                    </div>
+                }
+            </div>
+        </div>
+
+        <hr />
+
+        <!-- Logo Square -->
+        <div class="row mb-2 align-items-center">
+            <label asp-for="Input.Images.LogoSquareFile" class="col-sm-2 col-form-label">Logo-square</label>
+            <div class="col-sm-10">
+                @if (string.IsNullOrEmpty(Model.Input.Images.LogoSquarePath))
+                {
+                    <input asp-for="Input.Images.LogoSquareFile" class="form-control" accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml" />
+                    <span asp-validation-for="Input.Images.LogoSquareFile" class="text-danger"></span>
+                    <div class="form-text text-muted">정사각형 로고(프로필/아이콘 영역)에 사용됩니다. 권장: 512x512 PNG 투명 배경</div>
+                }
+                else
+                {
+                    <img src="@Model.Input.Images.LogoSquarePath" alt="logo-square" class="img-fluid rounded" />
+                    <div class="form-check mt-2">
+                        <input class="form-check-input" type="checkbox" value="1" name="delete_logo_square" id="deleteLogoSquare" />
+                        <label class="form-check-label" for="deleteLogoSquare">삭제</label>
+                    </div>
+                }
+            </div>
+        </div>
+
+        <hr />
+
+        <!-- Logo Horizontal -->
+        <div class="row mb-2 align-items-center">
+            <label asp-for="Input.Images.LogoHorizontalFile" class="col-sm-2 col-form-label">Logo-horizontal</label>
+            <div class="col-sm-10">
+                @if (string.IsNullOrEmpty(Model.Input.Images.LogoHorizontalPath))
+                {
+                    <input asp-for="Input.Images.LogoHorizontalFile" class="form-control" accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml" />
+                    <span asp-validation-for="Input.Images.LogoHorizontalFile" class="text-danger"></span>
+                    <div class="form-text text-muted">헤더/상단 영역에 사용되는 가로형 로고입니다. 권장: 가로 200~400px 이상</div>
+                }
+                else
+                {
+                    <img src="@Model.Input.Images.LogoHorizontalPath" alt="logo-horizontal" class="img-fluid rounded" />
+                    <div class="form-check mt-2">
+                        <input class="form-check-input" type="checkbox" value="1" name="delete_logo_horizontal" id="deleteLogoHorizontal" />
+                        <label class="form-check-label" for="deleteLogoHorizontal">삭제</label>
+                    </div>
+                }
+            </div>
+        </div>
+
+        <hr />
+
+        <!-- OG Default -->
+        <div class="row mb-2 align-items-center">
+            <label asp-for="Input.Images.OgDefaultFile" class="col-sm-2 col-form-label">og-default</label>
+            <div class="col-sm-10">
+                @if (string.IsNullOrEmpty(Model.Input.Images.OgDefaultPath))
+                {
+                    <input asp-for="Input.Images.OgDefaultFile" class="form-control" accept="image/png,image/jpeg,image/gif,image/webp" />
+                    <span asp-validation-for="Input.Images.OgDefaultFile" class="text-danger"></span>
+                    <div class="form-text text-muted">SNS 공유(Open Graph) 기본 이미지입니다. 권장: 1200x630</div>
+                }
+                else
+                {
+                    <img src="@Model.Input.Images.OgDefaultPath" alt="og-default" class="img-fluid rounded" />
+                    <div class="form-check mt-2">
+                        <input class="form-check-input" type="checkbox" value="1" name="delete_og_default" id="deleteOgDefault" />
+                        <label class="form-check-label" for="deleteOgDefault">삭제</label>
+                    </div>
+                }
+            </div>
+        </div>
+
+        <hr />
+
+        <!-- Twitter Image -->
+        <div class="row mb-2 align-items-center">
+            <label asp-for="Input.Images.TwitterImageFile" class="col-sm-2 col-form-label">Twitter-image</label>
+            <div class="col-sm-10">
+                @if (string.IsNullOrEmpty(Model.Input.Images.TwitterImagePath))
+                {
+                    <input asp-for="Input.Images.TwitterImageFile" class="form-control" accept="image/png,image/jpeg,image/gif,image/webp" />
+                    <span asp-validation-for="Input.Images.TwitterImageFile" class="text-danger"></span>
+                    <div class="form-text text-muted">트위터 카드 공유 이미지입니다. 권장: 1200x600 또는 1200x628</div>
+                }
+                else
+                {
+                    <img src="@Model.Input.Images.TwitterImagePath" alt="twitter-image" class="img-fluid rounded" />
+                    <div class="form-check mt-2">
+                        <input class="form-check-input" type="checkbox" value="1" name="delete_twitter_image" id="deleteTwitterImage" />
+                        <label class="form-check-label" for="deleteTwitterImage">삭제</label>
+                    </div>
+                }
+            </div>
+        </div>
+
+        <hr />
+
+        <!-- Apple Touch Icon -->
+        <div class="row mb-2 align-items-center">
+            <label asp-for="Input.Images.AppleTouchIconFile" class="col-sm-2 col-form-label">Apple-touch-icon</label>
+            <div class="col-sm-10">
+                @if (string.IsNullOrEmpty(Model.Input.Images.AppleTouchIconPath))
+                {
+                    <input asp-for="Input.Images.AppleTouchIconFile" class="form-control" accept="image/png,image/jpeg,image/gif,image/webp" />
+                    <span asp-validation-for="Input.Images.AppleTouchIconFile" class="text-danger"></span>
+                    <div class="form-text text-muted">iOS 홈 화면에 추가 시 아이콘으로 사용됩니다. 권장: 180x180 PNG</div>
+                }
+                else
+                {
+                    <img src="@Model.Input.Images.AppleTouchIconPath" alt="apple-touch-icon" class="img-fluid rounded" />
+                    <div class="form-check mt-2">
+                        <input class="form-check-input" type="checkbox" value="1" name="delete_apple_touch_icon" id="deleteAppleTouchIcon" />
+                        <label class="form-check-label" for="deleteAppleTouchIcon">삭제</label>
+                    </div>
+                }
+            </div>
+        </div>
+
+        <hr />
+
+        <!-- App Icon 192 -->
+        <div class="row mb-2 align-items-center">
+            <label asp-for="Input.Images.AppIcon192File" class="col-sm-2 col-form-label">App-icon-192</label>
+            <div class="col-sm-10">
+                @if (string.IsNullOrEmpty(Model.Input.Images.AppIcon192Path))
+                {
+                    <input asp-for="Input.Images.AppIcon192File" class="form-control" accept="image/png,image/jpeg,image/gif,image/webp" />
+                    <span asp-validation-for="Input.Images.AppIcon192File" class="text-danger"></span>
+                    <div class="form-text text-muted">PWA/앱 아이콘(192)으로 사용됩니다. 권장: 192x192 PNG</div>
+                }
+                else
+                {
+                    <img src="@Model.Input.Images.AppIcon192Path" alt="app-icon-192" class="img-fluid rounded" />
+                    <div class="form-check mt-2">
+                        <input class="form-check-input" type="checkbox" value="1" name="delete_app_icon_192" id="deleteAppIcon192" />
+                        <label class="form-check-label" for="deleteAppIcon192">삭제</label>
+                    </div>
+                }
+            </div>
+        </div>
+
+        <hr />
+
+        <!-- App Icon 512 -->
+        <div class="row mb-2 align-items-center">
+            <label asp-for="Input.Images.AppIcon512File" class="col-sm-2 col-form-label">App-icon-512</label>
+            <div class="col-sm-10">
+                @if (string.IsNullOrEmpty(Model.Input.Images.AppIcon512Path))
+                {
+                    <input asp-for="Input.Images.AppIcon512File" class="form-control" accept="image/png,image/jpeg,image/gif,image/webp" />
+                    <span asp-validation-for="Input.Images.AppIcon512File" class="text-danger"></span>
+                    <div class="form-text text-muted">PWA/앱 아이콘(512)으로 사용됩니다. 권장: 512x512 PNG</div>
+                }
+                else
+                {
+                    <img src="@Model.Input.Images.AppIcon512Path" alt="app-icon-512" class="img-fluid rounded" />
+                    <div class="form-check mt-2">
+                        <input class="form-check-input" type="checkbox" value="1" name="delete_app_icon_512" id="deleteAppIcon512" />
+                        <label class="form-check-label" for="deleteAppIcon512">삭제</label>
+                    </div>
+                }
+            </div>
+        </div>
+
+        <hr/>
+
+        <div class="row">
+            <div class="col text-center p-3">
+                <button type="submit" class="btn btn-success">저장하기</button>
+            </div>
+        </div>
+
+        <br />
+    </form>
+</div>
+
+@section Scripts {
+    @* <partial name="_ValidationScriptsPartial" /> *@
+}

+ 69 - 0
Admin/Pages/Config/Basic/Images.cshtml.cs

@@ -0,0 +1,69 @@
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace Admin.Pages.Config.Basic;
+
+public sealed class ImagesModel(IMediator mediator) : PageModel
+{
+    [BindProperty]
+    public InputModel Input { get; set; } = new();
+
+    public async Task OnGetAsync(CancellationToken ct)
+    {
+        var config = await mediator.Send(new GetConfig.Query(), ct);
+        if (config is not null)
+        {
+            Input = InputModel.From(config);
+        }
+    }
+
+    public async Task<IActionResult> OnPostAsync(CancellationToken ct)
+    {
+        if (!ModelState.IsValid)
+        {
+            return Page();
+        }
+
+        await mediator.Send(Input.ToCommand(Request), ct);
+
+        TempData["SuccessMessage"] = "저장되었습니다.";
+        return RedirectToPage();
+    }
+
+    public sealed class InputModel
+    {
+        public UpdateConfig.Request.ImagesConfigDto Images { get; set; } = new();
+
+        public static InputModel From(GetConfig.Response config)
+        {
+            var req = UpdateConfig.Request.From(config);
+
+            return new()
+            {
+                Images = req.Images
+            };
+        }
+
+        public UpdateConfig.Command ToCommand(HttpRequest request)
+        {
+            bool IsChecked(string key) => request.Form.TryGetValue(key, out var v) && v.Count > 0;
+
+            var delete = new UpdateConfig.Command.ImagesDeleteFlags(
+                Favicon: IsChecked("delete_favicon"),
+                LogoSquare: IsChecked("delete_logo_square"),
+                LogoHorizontal: IsChecked("delete_logo_horizontal"),
+                OgDefault: IsChecked("delete_og_default"),
+                TwitterImage: IsChecked("delete_twitter_image"),
+                AppleTouchIcon: IsChecked("delete_apple_touch_icon"),
+                AppIcon192: IsChecked("delete_app_icon_192"),
+                AppIcon512: IsChecked("delete_app_icon_512")
+            );
+
+            return new(
+                Images: Images,
+                ImagesDelete: delete
+            );
+        }
+    }
+}

+ 182 - 0
Admin/Pages/Config/Basic/Index.cshtml

@@ -0,0 +1,182 @@
+@page
+@model Admin.Pages.Config.Basic.IndexModel
+
+@{
+    ViewData["Title"] = "기본 설정";
+}
+
+<div class="container">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+    <partial name="_navTabs" />
+
+    <div asp-validation-summary="ModelOnly" class="text-danger"></div>
+    <partial name="_StatusMessage" />
+    <partial name="_Editor" />
+
+    <form name="f_admin_write" id="fAdminWrite" class="mt-3" method="post" autocomplete="off" accept-charset="UTF-8">
+        <div class="row mb-2">
+            <label asp-for="Input.Basic.SiteName" class="col-sm-2 col-form-label">관리자 제목</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Basic.SiteName" class="form-control" maxlength="100" />
+                <span asp-validation-for="Input.Basic.SiteName" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.Basic.SiteURL" class="col-sm-2 col-form-label">사이트 주소</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Basic.SiteURL" type="url" class="form-control" maxlength="100" />
+                <div class="form-text text-muted">웹 서비스 주소를 지정합니다.</div>
+                <span asp-validation-for="Input.Basic.SiteURL" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.Basic.FromEmail" class="col-sm-2 col-form-label">송수신 이메일</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Basic.FromEmail" type="email" class="form-control" maxlength="100" />
+                <div class="form-text text-muted">관리자가 보내고 받는 용도로 사용하는 메일 주소를 입력합니다.</div>
+                <span asp-validation-for="Input.Basic.FromEmail" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.Basic.FromName" class="col-sm-2 col-form-label">송수신자 이름</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Basic.FromName" class="form-control" maxlength="30" />
+                <div class="form-text text-muted">관리자가 보내고 받는 용도로 사용하는 메일의 발송자 이름을 입력합니다.</div>
+                <span asp-validation-for="Input.Basic.FromName" class="text-danger"></span>
+            </div>
+        </div>
+
+        <hr />
+
+        <div class="row mb-2">
+            <label asp-for="Input.Basic.SmtpServer" class="col-sm-2 col-form-label">SMTP Server</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Basic.SmtpServer" class="form-control" maxlength="200" />
+                <span asp-validation-for="Input.Basic.SmtpServer" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.Basic.SmtpPort" class="col-sm-2 col-form-label">SMTP Port</label>
+            <div class="col-sm-10 col-md-3">
+                <input asp-for="Input.Basic.SmtpPort" type="number" class="form-control" max="65535" />
+                <span asp-validation-for="Input.Basic.SmtpPort" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.Basic.SmtpEnableSSL" class="col-sm-2 col-form-label">SMTP Enable SSL</label>
+            <div class="col-sm-10 align-content-center">
+                <div class="form-check">
+                    <input asp-for="Input.Basic.SmtpEnableSSL" class="form-check-input" />
+                    <label class="form-check-label" for="Input_Basic_SmtpEnableSSL">사용</label>
+                </div>
+                <span asp-validation-for="Input.Basic.SmtpEnableSSL" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.Basic.SmtpUsername" class="col-sm-2 col-form-label">SMTP Username</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Basic.SmtpUsername" class="form-control" maxlength="100" />
+                <span asp-validation-for="Input.Basic.SmtpUsername" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.Basic.SmtpPassword" class="col-sm-2 col-form-label">SMTP Password</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Basic.SmtpPassword" type="password" class="form-control" maxlength="200" />
+                <span asp-validation-for="Input.Basic.SmtpPassword" class="text-danger"></span>
+            </div>
+        </div>
+
+        <hr />
+
+        <div class="row mb-2">
+            <label asp-for="Input.Basic.AdminWhiteIPList" class="col-sm-2 col-form-label">관리자단 접근 가능 IP</label>
+            <div class="col-sm-10">
+                <div class="form-text text-muted pb-2">
+                    해당 IP 에서만 관리자 페이지에 접근이 가능합니다. <br />
+                    IP 주소 입력형식 <br />
+                    1. 와일드카드 (*) 사용가능(예: 192.168.0.*) <br />
+                    2. 하이픈 (-)을 사용하여 대역으로 입력가능 <br />
+                    (단, 대역폭으로 입력할 경우 와일드카드 사용불가. 예: 192.168.0.1 ~ 192.168.0.254) <br />
+                    3. 여러개의 항목은 줄을 바꾸어 입력하세요.
+                </div>
+
+                <textarea asp-for="Input.Basic.AdminWhiteIPList" class="form-control" rows="3" maxlength="1000"></textarea>
+                <div class="form-text text-muted">해당 IP 에서만 관리자에 접근이 가능합니다.</div>
+                <span asp-validation-for="Input.Basic.AdminWhiteIPList" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.Basic.FrontWhiteIPList" class="col-sm-2 col-form-label">사용자단 접근 가능 IP</label>
+            <div class="col-sm-10">
+                <textarea asp-for="Input.Basic.FrontWhiteIPList" class="form-control" rows="2" maxlength="1000"></textarea>
+                <div class="form-text text-muted">해당 IP 에서만 사이트에 접근이 가능합니다.</div>
+                <span asp-validation-for="Input.Basic.FrontWhiteIPList" class="text-danger"></span>
+            </div>
+        </div>
+
+        <hr />
+
+        <div class="row mb-2">
+            <label asp-for="Input.Basic.BlockAlertTitle" class="col-sm-2 col-form-label">차단 시 안내문 제목</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Basic.BlockAlertTitle" class="form-control" maxlength="200" />
+                <span asp-validation-for="Input.Basic.BlockAlertTitle" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.Basic.BlockAlertContent" class="col-sm-2 col-form-label">차단 시 안내문 내용</label>
+            <div class="col-sm-10">
+                <textarea asp-for="Input.Basic.BlockAlertContent" id="blockAlertContent" class="ck-editor"></textarea>
+                <span asp-validation-for="Input.Basic.BlockAlertContent" class="text-danger"></span>
+            </div>
+        </div>
+
+        <hr />
+
+        <div class="row mb-2">
+            <label asp-for="Input.Basic.IsMaintenance" class="col-sm-2 col-form-label">점검 여부</label>
+            <div class="col-sm-10 align-content-center">
+                <div class="form-check-inline">
+                    <input asp-for="Input.Basic.IsMaintenance" class="form-check-input" />
+                    <label class="form-check-label" for="Input_Basic_IsMaintenance">점검을 진행합니다.</label>
+                </div>
+                <span asp-validation-for="Input.Basic.IsMaintenance" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.Basic.MaintenanceContent" class="col-sm-2 col-form-label">점검 내용</label>
+            <div class="col-sm-10">
+                <textarea asp-for="Input.Basic.MaintenanceContent" id="maintenanceContent" class="ck-editor"></textarea>
+                <span asp-validation-for="Input.Basic.MaintenanceContent" class="text-danger"></span>
+            </div>
+        </div>
+
+        <hr />
+
+        <div class="row">
+            <div class="col text-center p-3">
+                <button type="submit" class="btn btn-success">저장하기</button>
+            </div>
+        </div>
+
+        <br />
+    </form>
+</div>
+
+@section Scripts {
+    @{
+
+    }
+}

Некоторые файлы не были показаны из-за большого количества измененных файлов