Kaynağa Gözat

first commit

KIM-JINO5 3 hafta önce
işleme
cfebf095d1
100 değiştirilmiş dosya ile 7550 ekleme ve 0 silme
  1. 6 0
      .editorconfig
  2. 26 0
      .gitignore
  3. 50 0
      Admin/Admin.csproj
  4. 9 0
      Admin/Admin.slnx
  5. 10 0
      Admin/Areas/Identity/Pages/Account/AccessDenied.cshtml
  6. 22 0
      Admin/Areas/Identity/Pages/Account/AccessDenied.cshtml.cs
  7. 17 0
      Admin/Areas/Identity/Pages/Account/ConfirmEmail.cshtml
  8. 51 0
      Admin/Areas/Identity/Pages/Account/ConfirmEmail.cshtml.cs
  9. 17 0
      Admin/Areas/Identity/Pages/Account/ConfirmEmailChange.cshtml
  10. 69 0
      Admin/Areas/Identity/Pages/Account/ConfirmEmailChange.cshtml.cs
  11. 33 0
      Admin/Areas/Identity/Pages/Account/ExternalLogin.cshtml
  12. 223 0
      Admin/Areas/Identity/Pages/Account/ExternalLogin.cshtml.cs
  13. 42 0
      Admin/Areas/Identity/Pages/Account/ForgotPassword.cshtml
  14. 84 0
      Admin/Areas/Identity/Pages/Account/ForgotPassword.cshtml.cs
  15. 19 0
      Admin/Areas/Identity/Pages/Account/ForgotPasswordConfirmation.cshtml
  16. 24 0
      Admin/Areas/Identity/Pages/Account/ForgotPasswordConfirmation.cshtml.cs
  17. 10 0
      Admin/Areas/Identity/Pages/Account/Lockout.cshtml
  18. 24 0
      Admin/Areas/Identity/Pages/Account/Lockout.cshtml.cs
  19. 95 0
      Admin/Areas/Identity/Pages/Account/Login.cshtml
  20. 163 0
      Admin/Areas/Identity/Pages/Account/Login.cshtml.cs
  21. 39 0
      Admin/Areas/Identity/Pages/Account/LoginWith2fa.cshtml
  22. 131 0
      Admin/Areas/Identity/Pages/Account/LoginWith2fa.cshtml.cs
  23. 29 0
      Admin/Areas/Identity/Pages/Account/LoginWithRecoveryCode.cshtml
  24. 112 0
      Admin/Areas/Identity/Pages/Account/LoginWithRecoveryCode.cshtml.cs
  25. 21 0
      Admin/Areas/Identity/Pages/Account/Logout.cshtml
  26. 42 0
      Admin/Areas/Identity/Pages/Account/Logout.cshtml.cs
  27. 36 0
      Admin/Areas/Identity/Pages/Account/Manage/ChangePassword.cshtml
  28. 127 0
      Admin/Areas/Identity/Pages/Account/Manage/ChangePassword.cshtml.cs
  29. 33 0
      Admin/Areas/Identity/Pages/Account/Manage/DeletePersonalData.cshtml
  30. 103 0
      Admin/Areas/Identity/Pages/Account/Manage/DeletePersonalData.cshtml.cs
  31. 25 0
      Admin/Areas/Identity/Pages/Account/Manage/Disable2fa.cshtml
  32. 69 0
      Admin/Areas/Identity/Pages/Account/Manage/Disable2fa.cshtml.cs
  33. 12 0
      Admin/Areas/Identity/Pages/Account/Manage/DownloadPersonalData.cshtml
  34. 67 0
      Admin/Areas/Identity/Pages/Account/Manage/DownloadPersonalData.cshtml.cs
  35. 44 0
      Admin/Areas/Identity/Pages/Account/Manage/Email.cshtml
  36. 171 0
      Admin/Areas/Identity/Pages/Account/Manage/Email.cshtml.cs
  37. 53 0
      Admin/Areas/Identity/Pages/Account/Manage/EnableAuthenticator.cshtml
  38. 188 0
      Admin/Areas/Identity/Pages/Account/Manage/EnableAuthenticator.cshtml.cs
  39. 53 0
      Admin/Areas/Identity/Pages/Account/Manage/ExternalLogins.cshtml
  40. 141 0
      Admin/Areas/Identity/Pages/Account/Manage/ExternalLogins.cshtml.cs
  41. 27 0
      Admin/Areas/Identity/Pages/Account/Manage/GenerateRecoveryCodes.cshtml
  42. 82 0
      Admin/Areas/Identity/Pages/Account/Manage/GenerateRecoveryCodes.cshtml.cs
  43. 35 0
      Admin/Areas/Identity/Pages/Account/Manage/Index.cshtml
  44. 134 0
      Admin/Areas/Identity/Pages/Account/Manage/Index.cshtml.cs
  45. 122 0
      Admin/Areas/Identity/Pages/Account/Manage/ManageNavPages.cs
  46. 55 0
      Admin/Areas/Identity/Pages/Account/Manage/PersonalData.cshtml
  47. 36 0
      Admin/Areas/Identity/Pages/Account/Manage/PersonalData.cshtml.cs
  48. 24 0
      Admin/Areas/Identity/Pages/Account/Manage/ResetAuthenticator.cshtml
  49. 67 0
      Admin/Areas/Identity/Pages/Account/Manage/ResetAuthenticator.cshtml.cs
  50. 35 0
      Admin/Areas/Identity/Pages/Account/Manage/SetPassword.cshtml
  51. 114 0
      Admin/Areas/Identity/Pages/Account/Manage/SetPassword.cshtml.cs
  52. 25 0
      Admin/Areas/Identity/Pages/Account/Manage/ShowRecoveryCodes.cshtml
  53. 45 0
      Admin/Areas/Identity/Pages/Account/Manage/ShowRecoveryCodes.cshtml.cs
  54. 71 0
      Admin/Areas/Identity/Pages/Account/Manage/TwoFactorAuthentication.cshtml
  55. 89 0
      Admin/Areas/Identity/Pages/Account/Manage/TwoFactorAuthentication.cshtml.cs
  56. 27 0
      Admin/Areas/Identity/Pages/Account/Manage/_Layout.cshtml
  57. 16 0
      Admin/Areas/Identity/Pages/Account/Manage/_ManageNav.cshtml
  58. 10 0
      Admin/Areas/Identity/Pages/Account/Manage/_StatusMessage.cshtml
  59. 1 0
      Admin/Areas/Identity/Pages/Account/Manage/_ViewImports.cshtml
  60. 82 0
      Admin/Areas/Identity/Pages/Account/Register.cshtml
  61. 180 0
      Admin/Areas/Identity/Pages/Account/Register.cshtml.cs
  62. 31 0
      Admin/Areas/Identity/Pages/Account/RegisterConfirmation.cshtml
  63. 79 0
      Admin/Areas/Identity/Pages/Account/RegisterConfirmation.cshtml.cs
  64. 42 0
      Admin/Areas/Identity/Pages/Account/ResendEmailConfirmation.cshtml
  65. 88 0
      Admin/Areas/Identity/Pages/Account/ResendEmailConfirmation.cshtml.cs
  66. 47 0
      Admin/Areas/Identity/Pages/Account/ResetPassword.cshtml
  67. 117 0
      Admin/Areas/Identity/Pages/Account/ResetPassword.cshtml.cs
  68. 19 0
      Admin/Areas/Identity/Pages/Account/ResetPasswordConfirmation.cshtml
  69. 24 0
      Admin/Areas/Identity/Pages/Account/ResetPasswordConfirmation.cshtml.cs
  70. 10 0
      Admin/Areas/Identity/Pages/Account/_StatusMessage.cshtml
  71. 1 0
      Admin/Areas/Identity/Pages/Account/_ViewImports.cshtml
  72. 23 0
      Admin/Areas/Identity/Pages/Error.cshtml
  73. 40 0
      Admin/Areas/Identity/Pages/Error.cshtml.cs
  74. 2 0
      Admin/Areas/Identity/Pages/_ValidationScriptsPartial.cshtml
  75. 4 0
      Admin/Areas/Identity/Pages/_ViewImports.cshtml
  76. 12 0
      Admin/Areas/Identity/Pages/_ViewStart.cshtml
  77. 11 0
      Admin/Extensions/NavActiveExtension.cs
  78. 124 0
      Admin/Extensions/ServiceCollectionExtensions.cs
  79. 61 0
      Admin/Extensions/WebApplicationExtensions.cs
  80. 157 0
      Admin/Middlewares/AdminAccessLogMiddleware.cs
  81. 128 0
      Admin/Middlewares/MenuAuthorizationMiddleware.cs
  82. 198 0
      Admin/Pages/Banner/List/Edit.cshtml
  83. 151 0
      Admin/Pages/Banner/List/Edit.cshtml.cs
  84. 141 0
      Admin/Pages/Banner/List/Index.cshtml
  85. 95 0
      Admin/Pages/Banner/List/Index.cshtml.cs
  86. 126 0
      Admin/Pages/Banner/List/Write.cshtml
  87. 107 0
      Admin/Pages/Banner/List/Write.cshtml.cs
  88. 194 0
      Admin/Pages/Banner/Position.cshtml
  89. 105 0
      Admin/Pages/Banner/Position.cshtml.cs
  90. 8 0
      Admin/Pages/Banner/_NavTabs.cshtml
  91. 145 0
      Admin/Pages/Channel/Exp/Index.cshtml
  92. 99 0
      Admin/Pages/Channel/Exp/Index.cshtml.cs
  93. 182 0
      Admin/Pages/Channel/List/Edit.cshtml
  94. 169 0
      Admin/Pages/Channel/List/Edit.cshtml.cs
  95. 168 0
      Admin/Pages/Channel/List/Index.cshtml
  96. 127 0
      Admin/Pages/Channel/List/Index.cshtml.cs
  97. 267 0
      Admin/Pages/Channel/List/View.cshtml
  98. 84 0
      Admin/Pages/Channel/List/View.cshtml.cs
  99. 254 0
      Admin/Pages/Channel/List/Write.cshtml
  100. 143 0
      Admin/Pages/Channel/List/Write.cshtml.cs

+ 6 - 0
.editorconfig

@@ -0,0 +1,6 @@
+root = true
+
+[*.cs]
+# File-scoped namespace 선호 (C# 10+)
+csharp_style_namespace_declarations = file_scoped:warning
+dotnet_diagnostic.IDE0161.severity = warning

+ 26 - 0
.gitignore

@@ -0,0 +1,26 @@
+.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
+appsettings.Development.json
+appsettings.Production.json
+
+# 배포 참고용 폴더 — config-templates 에 실제 시크릿(JWT/Redis/Encryption 등) 포함.
+# 파이프라인은 이 폴더가 아니라 Jenkins Secret File 로 config 를 주입하므로 git 추적 제외.
+/deploy/

+ 50 - 0
Admin/Admin.csproj

@@ -0,0 +1,50 @@
+<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.7" />
+		<PackageReference Include="Microsoft.AspNetCore.Identity.UI" Version="10.0.7" />
+		<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
+			<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+			<PrivateAssets>all</PrivateAssets>
+		</PackageReference>
+		<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.7" />
+		<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.7">
+			<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+			<PrivateAssets>all</PrivateAssets>
+		</PackageReference>
+		<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.7" />
+		<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="10.0.2" />
+		<PackageReference Include="NuGet.Packaging" Version="7.3.1" />
+		<PackageReference Include="NuGet.Protocol" Version="7.3.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.7" />
+	</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>

+ 9 - 0
Admin/Admin.slnx

@@ -0,0 +1,9 @@
+<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" />
+  <Project Path="../MailWorker/MailWorker.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>

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

@@ -0,0 +1,22 @@
+// 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>

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

@@ -0,0 +1,51 @@
+// 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>

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

@@ -0,0 +1,69 @@
+// 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" />
+}

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

@@ -0,0 +1,223 @@
+// 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>ⓒ DPOT. 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" />
+}

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

@@ -0,0 +1,84 @@
+// 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>

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

@@ -0,0 +1,24 @@
+// 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>

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

@@ -0,0 +1,24 @@
+// 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>ⓒ DPOT. 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" />
+}

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

@@ -0,0 +1,163 @@
+// 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" />
+}

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

@@ -0,0 +1,131 @@
+// 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" />
+}

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

@@ -0,0 +1,112 @@
+// 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>

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

@@ -0,0 +1,42 @@
+// 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" />
+}

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

@@ -0,0 +1,127 @@
+// 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" />
+}

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

@@ -0,0 +1,103 @@
+// 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>

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

@@ -0,0 +1,69 @@
+// 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" />
+}

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

@@ -0,0 +1,67 @@
+// 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" />
+}

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

@@ -0,0 +1,171 @@
+// 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" />
+}

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

@@ -0,0 +1,188 @@
+// 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>
+}

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

@@ -0,0 +1,141 @@
+// 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>

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

@@ -0,0 +1,82 @@
+// 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" />
+}

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

@@ -0,0 +1,134 @@
+// 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();
+    }
+}

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

@@ -0,0 +1,122 @@
+// 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" />
+}

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

@@ -0,0 +1,36 @@
+// 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>

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

@@ -0,0 +1,67 @@
+// 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" />
+}

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

@@ -0,0 +1,114 @@
+// 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>

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

@@ -0,0 +1,45 @@
+// 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" />
+}

+ 89 - 0
Admin/Areas/Identity/Pages/Account/Manage/TwoFactorAuthentication.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 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>ⓒ DPOT. 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" />
+}

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

@@ -0,0 +1,180 @@
+// 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>

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

@@ -0,0 +1,79 @@
+// 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>ⓒ DPOT. 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" />
+}

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

@@ -0,0 +1,88 @@
+// 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" />
+}

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

@@ -0,0 +1,117 @@
+// 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>

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

@@ -0,0 +1,24 @@
+// 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>

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

@@ -0,0 +1,40 @@
+// 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";
+    }
+}

+ 11 - 0
Admin/Extensions/NavActiveExtension.cs

@@ -0,0 +1,11 @@
+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" : "";
+    }
+}

+ 124 - 0
Admin/Extensions/ServiceCollectionExtensions.cs

@@ -0,0 +1,124 @@
+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;
+
+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.KnownIPNetworks.Add(new System.Net.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;
+            };
+        });
+    }
+}

+ 61 - 0
Admin/Extensions/WebApplicationExtensions.cs

@@ -0,0 +1,61 @@
+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);
+            }
+        }
+    }
+}

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

@@ -0,0 +1,198 @@
+@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 class="text-danger">*</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 class="text-danger">*</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" placeholder="https://example.com" />
+                <span asp-validation-for="Input.Link" class="text-danger"></span>
+                <div class="form-check form-check-inline mt-2">
+                    <input type="radio" asp-for="Input.LinkTarget" value="_self" class="form-check-input" id="lt_self" />
+                    <label class="form-check-label" for="lt_self">현재 창</label>
+                </div>
+                <div class="form-check form-check-inline">
+                    <input type="radio" asp-for="Input.LinkTarget" value="_blank" class="form-check-input" id="lt_blank" />
+                    <label class="form-check-label" for="lt_blank">새 창</label>
+                </div>
+                <span asp-validation-for="Input.LinkTarget" 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)
+                        {
+                            <div id="currentDesktopWrap">
+                                <br />
+                                <img src="@Model.CurrentDesktopImage" class="img-thumbnail img-fluid rounded" />
+                                <br />
+                                <button type="button" class="btn btn-sm btn-danger mt-2" onclick="markRemove('Desktop')">이미지 삭제</button>
+                            </div>
+                            <div id="removeDesktopNotice" class="text-danger mt-2" hidden>
+                                Desktop 이미지가 삭제됩니다. <button type="button" class="btn btn-sm btn-secondary ms-2" onclick="undoRemove('Desktop')">취소</button>
+                            </div>
+                            <input type="checkbox" asp-for="Input.RemoveDesktopImage" id="Input_RemoveDesktopImage" hidden />
+                        }
+                        else
+                        {
+                            <text>-</text>
+                        }
+                    </div>
+                </div>
+                <div>
+                    <div class="form-text">
+                        Mobile
+                        @if (Model.CurrentMobileImage != null)
+                        {
+                            <div id="currentMobileWrap">
+                                <br />
+                                <img src="@Model.CurrentMobileImage" class="img-thumbnail img-fluid rounded" />
+                                <br />
+                                <button type="button" class="btn btn-sm btn-danger mt-2" onclick="markRemove('Mobile')">이미지 삭제</button>
+                            </div>
+                            <div id="removeMobileNotice" class="text-danger mt-2" hidden>
+                                Mobile 이미지가 삭제됩니다. <button type="button" class="btn btn-sm btn-secondary ms-2" onclick="undoRemove('Mobile')">취소</button>
+                            </div>
+                            <input type="checkbox" asp-for="Input.RemoveMobileImage" id="Input_RemoveMobileImage" hidden />
+                        }
+                        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");
+
+        function markRemove(kind) {
+            const cb = document.getElementById('Input_Remove' + kind + 'Image');
+            const wrap = document.getElementById('current' + kind + 'Wrap');
+            const notice = document.getElementById('remove' + kind + 'Notice');
+            if (cb) { cb.checked = true; }
+            if (wrap) { wrap.hidden = true; }
+            if (notice) { notice.hidden = false; }
+        }
+
+        function undoRemove(kind) {
+            const cb = document.getElementById('Input_Remove' + kind + 'Image');
+            const wrap = document.getElementById('current' + kind + 'Wrap');
+            const notice = document.getElementById('remove' + kind + 'Notice');
+            if (cb) { cb.checked = false; }
+            if (wrap) { wrap.hidden = false; }
+            if (notice) { notice.hidden = true; }
+        }
+    </script>
+}

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

@@ -0,0 +1,151 @@
+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("Desktop 이미지 삭제")]
+        public bool RemoveDesktopImage { get; set; } = false;
+
+        [DisplayName("Mobile 이미지 삭제")]
+        public bool RemoveMobileImage { get; set; } = false;
+
+        [DisplayName("링크 주소")]
+        [DataType(DataType.Url)]
+        [StringLength(255, ErrorMessage = "{0}은(는) {1}자 이하로 입력하세요.")]
+        public string? Link { get; set; }
+
+        [DisplayName("링크 타겟")]
+        [RegularExpression("^(_self|_blank)$", ErrorMessage = "{0} 값은 _self 또는 _blank만 허용합니다.")]
+        public string LinkTarget { get; set; } = "_self";
+
+        [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);
+        if (result.IsFailure)
+        {
+            TempData["ErrorMessages"] = result.Error.Description;
+            QueryString = Request.QueryString.ToString();
+            return;
+        }
+
+        var data = result.Value;
+
+        CurrentDesktopImage = data.DesktopImage;
+        CurrentMobileImage = data.MobileImage;
+
+        Input = new InputModel
+        {
+            ID = data.ID,
+            PositionID = data.PositionID,
+            Subject = data.Subject,
+            Link = data.Link,
+            LinkTarget = data.LinkTarget,
+            Order = data.Order,
+            IsActive = data.IsActive,
+            StartAt = data.StartAt,
+            EndAt = data.EndAt
+        };
+
+        QueryString = Request.QueryString.ToString();
+    }
+
+    public async Task<IActionResult> OnPostAsync(CancellationToken ct)
+    {
+        if (!ModelState.IsValid)
+        {
+            TempData["ErrorMessages"] = ModelState.GetErrorMessages();
+            return Redirect($"/Banner/List/Edit/{Input.ID}{Request.QueryString}");
+        }
+
+        var result = await mediator.Send(new UpdateBannerItem.Command(
+            Input.ID,
+            Input.PositionID,
+            Input.Subject,
+            Input.DesktopImageFile,
+            Input.MobileImageFile,
+            Input.RemoveDesktopImage,
+            Input.RemoveMobileImage,
+            Input.Link,
+            Input.LinkTarget,
+            Input.Order,
+            Input.IsActive,
+            Input.StartAt,
+            Input.EndAt
+        ), ct);
+
+        if (result.IsFailure)
+        {
+            TempData["ErrorMessages"] = result.Error.Description;
+        }
+        else
+        {
+            TempData["SuccessMessage"] = $"{Input.Subject} 배너가 수정되었습니다.";
+        }
+
+        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);
+    }
+}

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

@@ -0,0 +1,126 @@
+@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 class="text-danger">*</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 class="text-danger">*</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" placeholder="https://example.com" />
+                <span asp-validation-for="Input.Link" class="text-danger"></span>
+                <div class="form-check form-check-inline mt-2">
+                    <input type="radio" asp-for="Input.LinkTarget" value="_self" class="form-check-input" id="lt_self" />
+                    <label class="form-check-label" for="lt_self">현재 창</label>
+                </div>
+                <div class="form-check form-check-inline">
+                    <input type="radio" asp-for="Input.LinkTarget" value="_blank" class="form-check-input" id="lt_blank" />
+                    <label class="form-check-label" for="lt_blank">새 창</label>
+                </div>
+                <span asp-validation-for="Input.LinkTarget" 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>
+}

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

@@ -0,0 +1,107 @@
+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("링크 타겟")]
+        [RegularExpression("^(_self|_blank)$", ErrorMessage = "{0} 값은 _self 또는 _blank만 허용합니다.")]
+        public string LinkTarget { get; set; } = "_self";
+
+        [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)
+    {
+        if (!ModelState.IsValid)
+        {
+            TempData["ErrorMessages"] = ModelState.GetErrorMessages();
+            return Redirect($"/Banner/List/Write?{Request.QueryString}");
+        }
+
+        var result = await mediator.Send(new CreateBannerItem.Command(
+            Input.PositionID,
+            Input.Subject,
+            Input.DesktopImageFile,
+            Input.MobileImageFile,
+            Input.Link,
+            Input.LinkTarget,
+            Input.Order,
+            Input.IsActive,
+            Input.StartAt,
+            Input.EndAt
+        ), ct);
+
+        if (result.IsFailure)
+        {
+            TempData["ErrorMessages"] = result.Error.Description;
+            return Redirect($"/Banner/List/Write?{Request.QueryString}");
+        }
+
+        TempData["SuccessMessage"] = $"{Input.Subject} 배너가 등록되었습니다.";
+
+        return RedirectToPage("/Banner/List/Index");
+    }
+}

+ 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>

+ 145 - 0
Admin/Pages/Channel/Exp/Index.cshtml

@@ -0,0 +1,145 @@
+@page
+@model Admin.Pages.Channel.Exp.IndexModel
+@{
+    ViewData["Title"] = "채널 경험치 관리";
+}
+
+<div class="container">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <div asp-validation-summary="ModelOnly" class="text-danger"></div>
+    <partial name="_StatusMessage" />
+
+    <form method="post" accept-charset="utf-8" autocomplete="off">
+        <div class="alert alert-info" role="alert">
+            watch 페이지의 채팅 경험치 / 리더보드 정책을 설정합니다.
+            <br />
+            채팅 참여 시마다 XP 가 적립되며, 방송 세션 단위로 리더보드가 집계됩니다 (YouTube 열성 팬 방식).
+        </div>
+
+        <hr />
+
+        <!-- 적립 정책 -->
+        <details open>
+            <summary class="fs-5 mb-3">적립 정책</summary>
+
+            <div class="row mb-2">
+                <label asp-for="Input.ChatXpPerMessage" class="col-sm-3 col-form-label"></label>
+                <div class="col-sm-9">
+                    <input type="number" asp-for="Input.ChatXpPerMessage" class="form-control" min="0" max="100" />
+                    <div class="text-muted form-text">채팅 1건이 지급하는 XP (기본 1)</div>
+                    <span asp-validation-for="Input.ChatXpPerMessage" class="text-danger"></span>
+                </div>
+            </div>
+
+            <div class="row mb-2">
+                <label asp-for="Input.DonationXpPerAmount" class="col-sm-3 col-form-label"></label>
+                <div class="col-sm-9">
+                    <div class="input-group">
+                        <input type="number" asp-for="Input.DonationXpPerAmount" class="form-control" min="1" step="100" />
+                        <span class="input-group-text">POINT / 1 XP</span>
+                    </div>
+                    <div class="text-muted form-text">N POINT당 1 XP 환산 (기본 1000 = 1,000원당 1XP)</div>
+                    <span asp-validation-for="Input.DonationXpPerAmount" class="text-danger"></span>
+                </div>
+            </div>
+
+            <div class="row mb-2">
+                <label asp-for="Input.ChatXpSessionLimit" class="col-sm-3 col-form-label"></label>
+                <div class="col-sm-9">
+                    <input type="number" asp-for="Input.ChatXpSessionLimit" class="form-control" min="0" max="10000" />
+                    <div class="text-muted form-text">방송 1회당 채팅으로 쌓을 수 있는 최대 XP (후원 XP 는 상한 없음)</div>
+                    <span asp-validation-for="Input.ChatXpSessionLimit" class="text-danger"></span>
+                </div>
+            </div>
+
+            <div class="row mb-2">
+                <label asp-for="Input.MinContentLength" class="col-sm-3 col-form-label"></label>
+                <div class="col-sm-9">
+                    <input type="number" asp-for="Input.MinContentLength" class="form-control" min="1" max="500" />
+                    <div class="text-muted form-text">이 글자수 미만 채팅은 XP 가 적립되지 않음 (도배 방지)</div>
+                    <span asp-validation-for="Input.MinContentLength" class="text-danger"></span>
+                </div>
+            </div>
+
+            <div class="row mb-2">
+                <label asp-for="Input.RateLimitSec" class="col-sm-3 col-form-label"></label>
+                <div class="col-sm-9">
+                    <div class="input-group">
+                        <input type="number" asp-for="Input.RateLimitSec" class="form-control" min="0" max="60" />
+                        <span class="input-group-text">초</span>
+                    </div>
+                    <div class="text-muted form-text">채팅 최소 간격 (기본 2초)</div>
+                    <span asp-validation-for="Input.RateLimitSec" class="text-danger"></span>
+                </div>
+            </div>
+        </details>
+
+        <hr />
+
+        <!-- 리더보드 -->
+        <details open>
+            <summary class="fs-5 mb-3">리더보드</summary>
+
+            <div class="row mb-2">
+                <label asp-for="Input.LeaderboardSize" class="col-sm-3 col-form-label"></label>
+                <div class="col-sm-9">
+                    <input type="number" asp-for="Input.LeaderboardSize" class="form-control" min="1" max="500" />
+                    <div class="text-muted form-text">리더보드에 표시할 상위 N명 (기본 50)</div>
+                    <span asp-validation-for="Input.LeaderboardSize" class="text-danger"></span>
+                </div>
+            </div>
+
+            <div class="row mb-2">
+                <label asp-for="Input.CrownTopN" class="col-sm-3 col-form-label"></label>
+                <div class="col-sm-9">
+                    <input type="number" asp-for="Input.CrownTopN" class="form-control" min="0" max="10" />
+                    <div class="text-muted form-text">채팅 옆에 크라운을 표시할 상위 N명 (기본 3)</div>
+                    <span asp-validation-for="Input.CrownTopN" class="text-danger"></span>
+                </div>
+            </div>
+        </details>
+
+        <hr />
+
+        <!-- 사용자 UI -->
+        <details open>
+            <summary class="fs-5 mb-3">사용자 UI</summary>
+
+            <div class="row mb-2">
+                <label asp-for="Input.UxShowLeaderboard" class="col-sm-3 col-form-label">리더보드 패널 노출</label>
+                <div class="col-sm-9 align-self-center">
+                    <div class="form-check">
+                        <input type="checkbox" asp-for="Input.UxShowLeaderboard" class="form-check-input" />
+                        <label class="form-check-label" asp-for="Input.UxShowLeaderboard">사용합니다.</label>
+                    </div>
+                </div>
+            </div>
+
+            <div class="row mb-2">
+                <label asp-for="Input.UxShowMyXpBadge" class="col-sm-3 col-form-label">"내 XP" 뱃지 노출</label>
+                <div class="col-sm-9 align-self-center">
+                    <div class="form-check">
+                        <input type="checkbox" asp-for="Input.UxShowMyXpBadge" class="form-check-input" />
+                        <label class="form-check-label" asp-for="Input.UxShowMyXpBadge">사용합니다.</label>
+                    </div>
+                </div>
+            </div>
+        </details>
+
+        <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" />
+}

+ 99 - 0
Admin/Pages/Channel/Exp/Index.cshtml.cs

@@ -0,0 +1,99 @@
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Channel.Exp;
+
+public class IndexModel(IMediator mediator) : PageModel
+{
+    [BindProperty]
+    public InputModel Input { get; set; } = new();
+
+    public sealed class InputModel
+    {
+        [DisplayName("채팅 메시지당 XP")]
+        [Range(0, 100, ErrorMessage = "{0}은(는) {1}~{2} 사이여야 합니다.")]
+        public int ChatXpPerMessage { get; set; } = 1;
+
+        [DisplayName("후원 POINT당 1 XP 환산 기준")]
+        [Range(1, 1000000, ErrorMessage = "{0}은(는) {1} 이상이어야 합니다.")]
+        public int DonationXpPerAmount { get; set; } = 1000;
+
+        [DisplayName("방송 세션당 채팅 XP 상한")]
+        [Range(0, 10000, ErrorMessage = "{0}은(는) {1}~{2} 사이여야 합니다.")]
+        public int ChatXpSessionLimit { get; set; } = 50;
+
+        [DisplayName("XP 적립 최소 글자수")]
+        [Range(1, 500, ErrorMessage = "{0}은(는) {1}~{2} 사이여야 합니다.")]
+        public int MinContentLength { get; set; } = 2;
+
+        [DisplayName("채팅 쿨다운(초)")]
+        [Range(0, 60, ErrorMessage = "{0}은(는) {1}~{2} 사이여야 합니다.")]
+        public int RateLimitSec { get; set; } = 2;
+
+        [DisplayName("리더보드 표시 인원")]
+        [Range(1, 500, ErrorMessage = "{0}은(는) {1}~{2} 사이여야 합니다.")]
+        public int LeaderboardSize { get; set; } = 50;
+
+        [DisplayName("크라운 뱃지 부여 Top N")]
+        [Range(0, 10, ErrorMessage = "{0}은(는) {1}~{2} 사이여야 합니다.")]
+        public int CrownTopN { get; set; } = 3;
+
+        [DisplayName("리더보드 노출")]
+        public bool UxShowLeaderboard { get; set; } = true;
+
+        [DisplayName("내 XP 뱃지 노출")]
+        public bool UxShowMyXpBadge { get; set; } = true;
+    }
+
+    public async Task OnGetAsync(CancellationToken ct)
+    {
+        var data = await mediator.Send(new GetChannelExpConfig.Query(), ct);
+
+        Input = new InputModel
+        {
+            ChatXpPerMessage = data.ChatXpPerMessage,
+            DonationXpPerAmount = data.DonationXpPerAmount,
+            ChatXpSessionLimit = data.ChatXpSessionLimit,
+            MinContentLength = data.MinContentLength,
+            RateLimitSec = data.RateLimitSec,
+            LeaderboardSize = data.LeaderboardSize,
+            CrownTopN = data.CrownTopN,
+            UxShowLeaderboard = data.UxShowLeaderboard,
+            UxShowMyXpBadge = data.UxShowMyXpBadge
+        };
+    }
+
+    public async Task<IActionResult> OnPostAsync(CancellationToken ct)
+    {
+        if (!ModelState.IsValid)
+        {
+            return Page();
+        }
+
+        try
+        {
+            await mediator.Send(new UpdateChannelExpConfig.Command(
+                Input.ChatXpPerMessage,
+                Input.DonationXpPerAmount,
+                Input.ChatXpSessionLimit,
+                Input.MinContentLength,
+                Input.RateLimitSec,
+                Input.LeaderboardSize,
+                Input.CrownTopN,
+                Input.UxShowLeaderboard,
+                Input.UxShowMyXpBadge
+            ), ct);
+
+            TempData["SuccessMessage"] = "경험치 설정이 저장되었습니다.";
+        }
+        catch (Exception e)
+        {
+            TempData["ErrorMessages"] = e.Message;
+        }
+
+        return RedirectToPage("/Channel/Exp/Index");
+    }
+}

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

@@ -0,0 +1,182 @@
+@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" asp-for="Input.ID" class="form-control-plaintext" readonly />
+            </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.DonationCode" class="col-sm-2 col-form-label">후원 코드</label>
+            <div class="col-sm-10">
+                <div class="row">
+                    <div class="col col-md-auto">
+                        <div class="input-group">
+                            <input type="text" asp-for="Input.DonationCode" id="channelDonationCode" class="form-control" maxlength="7" placeholder="자동 생성" autocomplete="off" />
+                            <button type="button" id="btnRegenerateDonationCode" class="btn btn-outline-secondary" title="새 코드 재생성">
+                                <i class="fa-solid fa-rotate"></i> 재생성
+                            </button>
+                        </div>
+                    </div>
+                </div>
+                <small class="form-text text-muted">자동 생성된 6자 영숫자 코드 (UNIQUE, 대문자). 직접 4~7자 영문/숫자로 입력하거나 재생성 버튼으로 갱신할 수 있습니다. 비워두면 후원 코드를 해제합니다.</small>
+                <span asp-validation-for="Input.DonationCode" class="text-danger"></span>
+            </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="any" />
+                            <span class="input-group-text">%</span>
+                        </div>
+                    </div>
+                </div>
+                <small class="form-text text-muted">후원 받은 금액 중 dpot 이 가져가는 비율 (채널주는 100-PlatformFee% 보유)</small>
+                <span asp-validation-for="Input.PlatformFeeRate" class="text-danger"></span>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Input.StoreCommissionRate" 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.StoreCommissionRate" class="form-control" required min="0" max="100" step="any" />
+                            <span class="input-group-text">%</span>
+                        </div>
+                    </div>
+                </div>
+                <small class="form-text text-muted">상점 후원 시 채널주에게 보상으로 지급하는 비율 (StoreRevenue 잔액에 입금)</small>
+                <span asp-validation-for="Input.StoreCommissionRate" 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" asp-for="Input.UpdatedAt" class="form-control-plaintext" readonly />
+                </div>
+            </div>
+        }
+        <div class="row mb-2">
+            <label class="col-sm-2 col-form-label">등록일시</label>
+            <div class="col-sm-10">
+                <input type="text" asp-for="Input.CreatedAt" class="form-control-plaintext" readonly />
+            </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>
+
+@section Scripts {
+<script>
+    // 후원 코드 재생성
+    document.getElementById('btnRegenerateDonationCode').addEventListener('click', async function () {
+        const btn = this;
+        btn.disabled = true;
+        try {
+            const res = await fetch('?handler=GenerateDonationCode', {
+                method: 'GET',
+                headers: { 'Accept': 'application/json' }
+            });
+            if (!res.ok) {
+                throw new Error('코드 생성 실패');
+            }
+            const data = await res.json();
+            if (data && data.code) {
+                document.getElementById('channelDonationCode').value = data.code;
+            }
+        } catch (e) {
+            alert('후원 코드 재생성 중 오류가 발생했습니다.');
+        } finally {
+            btn.disabled = false;
+        }
+    });
+</script>
+}

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

@@ -0,0 +1,169 @@
+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("상점 보상률(%)")]
+        [Range(0, 100, ErrorMessage = "{0}은 {1}~{2} 사이여야 합니다.")]
+        public decimal StoreCommissionRate { get; set; }
+
+        [DisplayName("인증 여부")]
+        public bool IsVerified { get; set; }
+
+        [DisplayName("사용 여부")]
+        public bool IsActive { get; set; }
+
+        [DisplayName("후원 코드")]
+        [StringLength(7, MinimumLength = 4, ErrorMessage = "{0}은 {2}~{1}자여야 합니다.")]
+        [RegularExpression("^[A-Za-z0-9]{4,7}$", ErrorMessage = "{0}은 영문/숫자만 입력 가능합니다.")]
+        public string? DonationCode { 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 ?? "-"}";
+
+        // 기존 코드가 없으면 자동 추천값 생성 (저장 안 하면 NULL 유지)
+        var donationCode = result.DonationCode
+            ?? await mediator.Send(new GenerateChannelDonationCode.Query(), ct);
+
+        Input = new InputModel
+        {
+            ID = result.ID,
+            MemberID = result.MemberID,
+            Name = result.Name,
+            Handle = result.Handle ?? "-",
+            YouTubeUrl = result.YouTubeUrl,
+            PlatformFeeRate = result.PlatformFeeRate,
+            StoreCommissionRate = result.StoreCommissionRate,
+            IsVerified = result.IsVerified,
+            IsActive = result.IsActive,
+            DonationCode = donationCode,
+            UpdatedAt = result.UpdatedAt.GetDateAt() ?? "-",
+            CreatedAt = result.CreatedAt.GetDateAt()
+        };
+
+        QueryString = Request.QueryString.ToString();
+    }
+
+    public async Task<IActionResult> OnGetGenerateDonationCodeAsync(CancellationToken ct)
+    {
+        var code = await mediator.Send(new GenerateChannelDonationCode.Query(), ct);
+        return new JsonResult(new { code });
+    }
+
+    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.StoreCommissionRate,
+                Input.IsVerified,
+                Input.IsActive,
+                Input.DonationCode
+            ), 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 (InvalidOperationException e)
+        {
+            TempData["ErrorMessages"] = e.Message;
+
+            return Redirect($"/Channel/List/Edit/{Input.ID}{Request.QueryString}");
+        }
+        catch (Microsoft.EntityFrameworkCore.DbUpdateException e)
+        {
+            var detail = e.InnerException?.Message ?? e.Message;
+            TempData["ErrorMessages"] = $"데이터베이스 제약 조건 위반으로 삭제할 수 없습니다. 연관된 데이터를 먼저 정리해 주세요.\n상세: {detail}";
+
+            return Redirect($"/Channel/List/Edit/{Input.ID}{Request.QueryString}");
+        }
+        catch (Exception e)
+        {
+            TempData["ErrorMessages"] = e.Message;
+
+            return Redirect($"/Channel/List/Edit/{Input.ID}{Request.QueryString}");
+        }
+    }
+}

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

@@ -0,0 +1,168 @@
+@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)">회원 PK</option>
+                <option value="6" selected="@(Model.Query.Search == 6)">회원 이메일</option>
+                <option value="7" selected="@(Model.Query.Search == 7)">회원 이름</option>
+                <option value="8" selected="@(Model.Query.Search == 8)">회원 SID</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-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 style="width:60px">썸네일</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 class="text-center">
+                                @if (!string.IsNullOrEmpty(row.ThumbnailUrl))
+                                {
+                                    <img src="@row.ThumbnailUrl" class="img-thumbnail" style="width:48px;height:48px;object-fit:cover;padding:1px" alt="@row.Name" loading="lazy" referrerpolicy="no-referrer" />
+                                }
+                                else
+                                {
+                                    <div class="img-thumbnail d-inline-flex align-items-center justify-content-center text-secondary" style="width:48px;height:48px;padding:1px;background:#f0f0f0">
+                                        <i class="bi bi-person-circle" style="font-size:1.4rem"></i>
+                                    </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["page"].value = "1";
+            searchForm.submit();
+        });
+    </script>
+}

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

@@ -0,0 +1,127 @@
+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? ThumbnailUrl,
+        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.ThumbnailUrl,
+            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 (InvalidOperationException e)
+        {
+            TempData["ErrorMessages"] = e.Message;
+        }
+        catch (Microsoft.EntityFrameworkCore.DbUpdateException e)
+        {
+            var detail = e.InnerException?.Message ?? e.Message;
+            TempData["ErrorMessages"] = $"데이터베이스 제약 조건 위반으로 삭제할 수 없습니다. 연관된 데이터를 먼저 정리해 주세요.\n상세: {detail}";
+        }
+        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");
+    }
+}

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

@@ -0,0 +1,84 @@
+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, IYouTubeChannelCache channelCache) : 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);
+
+            // YouTube API 조회 성공 시 Redis 캐시에 저장 (24시간 TTL)
+            if (YouTubeChannel is not null)
+            {
+                await channelCache.SetAsync(YouTubeChannel);
+            }
+
+            if (YouTubeChannel is null)
+            {
+                YouTubeApiFailed = true;
+                YouTubeApiError = $"채널 정보를 찾을 수 없습니다. 조회한 SID: \"{channelId}\" — DB Config의 YouTube API Key 설정을 확인하세요.";
+            }
+        }
+        catch (Exception ex)
+        {
+            YouTubeApiFailed = true;
+            YouTubeApiError = $"YouTube API 조회 실패: {ex.Message}";
+        }
+    }
+}

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

@@ -0,0 +1,254 @@
+@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.DonationCode" class="col-sm-2 col-form-label">후원 코드</label>
+            <div class="col-sm-10">
+                <div class="row">
+                    <div class="col col-md-auto">
+                        <div class="input-group">
+                            <input type="text" asp-for="Input.DonationCode" id="channelDonationCode" class="form-control" maxlength="7" placeholder="자동 생성" autocomplete="off" />
+                            <button type="button" id="btnRegenerateDonationCode" class="btn btn-outline-secondary" title="새 코드 재생성">
+                                <i class="fa-solid fa-rotate"></i> 재생성
+                            </button>
+                        </div>
+                    </div>
+                </div>
+                <small class="form-text text-muted">자동 생성된 6자 영숫자 코드 (UNIQUE, 대문자). 직접 4~7자 영문/숫자로 입력하거나 재생성 버튼으로 갱신할 수 있습니다. 비워두면 후원 코드를 사용하지 않습니다.</small>
+                <span asp-validation-for="Input.DonationCode" class="text-danger"></span>
+            </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="any" />
+                            <span class="input-group-text">%</span>
+                        </div>
+                    </div>
+                </div>
+                <small class="form-text text-muted">후원 받은 금액 중 dpot 이 가져가는 비율 (채널주는 100-PlatformFee% 보유)</small>
+                <span asp-validation-for="Input.PlatformFeeRate" class="text-danger"></span>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Input.StoreCommissionRate" 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.StoreCommissionRate" class="form-control" required min="0" max="100" step="any" />
+                            <span class="input-group-text">%</span>
+                        </div>
+                    </div>
+                </div>
+                <small class="form-text text-muted">상점 후원 시 채널주에게 보상으로 지급하는 비율 (StoreRevenue 잔액에 입금)</small>
+                <span asp-validation-for="Input.StoreCommissionRate" 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();
+            }
+        });
+
+        // 후원 코드 재생성
+        $('#btnRegenerateDonationCode').on('click', async function () {
+            const btn = this;
+            btn.disabled = true;
+            try {
+                const res = await fetch('?handler=GenerateDonationCode', {
+                    method: 'GET',
+                    headers: { 'Accept': 'application/json' }
+                });
+                if (!res.ok) {
+                    throw new Error('코드 생성 실패');
+                }
+                const data = await res.json();
+                if (data && data.code) {
+                    document.getElementById('channelDonationCode').value = data.code;
+                }
+            } catch (e) {
+                alert('후원 코드 재생성 중 오류가 발생했습니다.');
+            } finally {
+                btn.disabled = false;
+            }
+        });
+    });
+</script>
+}

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

@@ -0,0 +1,143 @@
+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("상점 보상률(%)")]
+        [Range(0, 100, ErrorMessage = "{0}은 {1}~{2} 사이여야 합니다.")]
+        public decimal StoreCommissionRate { get; set; } = 0;
+
+        [DisplayName("인증 여부")]
+        public bool IsVerified { get; set; } = false;
+
+        [DisplayName("사용 여부")]
+        public bool IsActive { get; set; } = false;
+
+        [DisplayName("후원 코드")]
+        [StringLength(7, MinimumLength = 4, ErrorMessage = "{0}은 {2}~{1}자여야 합니다.")]
+        [RegularExpression("^[A-Za-z0-9]{4,7}$", ErrorMessage = "{0}은 영문/숫자만 입력 가능합니다.")]
+        public string? DonationCode { get; set; }
+    }
+
+    public async Task OnGetAsync(CancellationToken ct)
+    {
+        QueryString = HttpContext.Request.QueryString.HasValue ? HttpContext.Request.QueryString.Value!.TrimStart('?') : "";
+
+        // 후원 코드 자동 생성 (사용자가 그대로 사용하거나 재생성 버튼으로 갱신)
+        Input.DonationCode = await mediator.Send(new GenerateChannelDonationCode.Query(), ct);
+    }
+
+    public async Task<IActionResult> OnGetGenerateDonationCodeAsync(CancellationToken ct)
+    {
+        var code = await mediator.Send(new GenerateChannelDonationCode.Query(), ct);
+        return new JsonResult(new { code });
+    }
+
+    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.StoreCommissionRate,
+                Input.IsVerified,
+                Input.IsActive,
+                Input.DonationCode
+            ), ct);
+
+            TempData["SuccessMessage"] = "채널이 등록되었습니다.";
+
+            return RedirectToPage("/Channel/List/Index");
+        }
+        catch (Exception e)
+        {
+            TempData["ErrorMessages"] = e.Message;
+
+            return Page();
+        }
+    }
+}

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor