From 028cc39a40c5455a8564c648c3fa8bb7155d784e Mon Sep 17 00:00:00 2001 From: warrenchen Date: Fri, 17 Apr 2026 16:29:50 +0900 Subject: [PATCH 1/2] feat: Enhance user profile and address management, add SMTP settings retrieval, and improve navigation links --- .../Abstractions/ISecuritySettingsService.cs | 1 + .../Persistence/MemberCenterDbContext.cs | 6 +- ...22_AddUserProfilesAndAddresses.Designer.cs | 2 +- ...60416161322_AddUserProfilesAndAddresses.cs | 2 +- ...ChangeUserAddressDefaultToSinglePerUser.cs | 90 +++++++++++++++++++ .../MemberCenterDbContextModelSnapshot.cs | 6 +- .../Services/ProfileService.cs | 54 ++++++++--- .../Services/SecuritySettingsService.cs | 80 +++++++++-------- .../Services/SmtpEmailSender.cs | 18 ++-- .../Controllers/AccountController.cs | 13 +-- .../Controllers/ProfileController.cs | 23 ++--- .../Models/Profile/ProfileViewModel.cs | 1 + src/MemberCenter.Web/Program.cs | 11 ++- .../Views/Account/Login.cshtml | 4 + .../Views/Profile/Index.cshtml | 16 ++-- .../Views/Profile/Subscriptions.cshtml | 7 +- .../Views/Shared/_Layout.cshtml | 2 + 17 files changed, 245 insertions(+), 91 deletions(-) create mode 100644 src/MemberCenter.Infrastructure/Persistence/Migrations/20260417043000_ChangeUserAddressDefaultToSinglePerUser.cs diff --git a/src/MemberCenter.Application/Abstractions/ISecuritySettingsService.cs b/src/MemberCenter.Application/Abstractions/ISecuritySettingsService.cs index 8f02c1a..33fc2a5 100644 --- a/src/MemberCenter.Application/Abstractions/ISecuritySettingsService.cs +++ b/src/MemberCenter.Application/Abstractions/ISecuritySettingsService.cs @@ -5,6 +5,7 @@ namespace MemberCenter.Application.Abstractions; public interface ISecuritySettingsService { Task GetAsync(); + Task GetSmtpSettingsAsync(); Task SaveAsync(SecuritySettingsDto settings, Guid? actorUserId = null); Task SendTestEmailAsync(string toEmail, Guid? actorUserId = null); } diff --git a/src/MemberCenter.Infrastructure/Persistence/MemberCenterDbContext.cs b/src/MemberCenter.Infrastructure/Persistence/MemberCenterDbContext.cs index 06fa2e4..0fff057 100644 --- a/src/MemberCenter.Infrastructure/Persistence/MemberCenterDbContext.cs +++ b/src/MemberCenter.Infrastructure/Persistence/MemberCenterDbContext.cs @@ -129,10 +129,10 @@ public class MemberCenterDbContext entity.HasIndex(x => x.UserId).HasDatabaseName("idx_user_addresses_user_id"); entity.HasIndex(x => new { x.UserId, x.Usage }) .HasDatabaseName("idx_user_addresses_user_id_usage"); - entity.HasIndex(x => new { x.UserId, x.Usage, x.IsDefault }) + entity.HasIndex(x => new { x.UserId, x.IsDefault }) .IsUnique() - .HasFilter("\"is_default\" = true") - .HasDatabaseName("ux_user_addresses_default_per_usage"); + .HasFilter("\"IsDefault\" = true") + .HasDatabaseName("ux_user_addresses_default_per_user"); entity.HasOne() .WithMany(x => x.Addresses) .HasForeignKey(x => x.UserId) diff --git a/src/MemberCenter.Infrastructure/Persistence/Migrations/20260416161322_AddUserProfilesAndAddresses.Designer.cs b/src/MemberCenter.Infrastructure/Persistence/Migrations/20260416161322_AddUserProfilesAndAddresses.Designer.cs index f1afb23..371d594 100644 --- a/src/MemberCenter.Infrastructure/Persistence/Migrations/20260416161322_AddUserProfilesAndAddresses.Designer.cs +++ b/src/MemberCenter.Infrastructure/Persistence/Migrations/20260416161322_AddUserProfilesAndAddresses.Designer.cs @@ -380,7 +380,7 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations b.HasIndex("UserId", "Usage", "IsDefault") .IsUnique() .HasDatabaseName("ux_user_addresses_default_per_usage") - .HasFilter("\"is_default\" = true"); + .HasFilter("\"IsDefault\" = true"); b.ToTable("user_addresses", (string)null); }); diff --git a/src/MemberCenter.Infrastructure/Persistence/Migrations/20260416161322_AddUserProfilesAndAddresses.cs b/src/MemberCenter.Infrastructure/Persistence/Migrations/20260416161322_AddUserProfilesAndAddresses.cs index d1c5191..ba1bb0d 100644 --- a/src/MemberCenter.Infrastructure/Persistence/Migrations/20260416161322_AddUserProfilesAndAddresses.cs +++ b/src/MemberCenter.Infrastructure/Persistence/Migrations/20260416161322_AddUserProfilesAndAddresses.cs @@ -127,7 +127,7 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations table: "user_addresses", columns: new[] { "UserId", "Usage", "IsDefault" }, unique: true, - filter: "\"is_default\" = true"); + filter: "\"IsDefault\" = true"); } /// diff --git a/src/MemberCenter.Infrastructure/Persistence/Migrations/20260417043000_ChangeUserAddressDefaultToSinglePerUser.cs b/src/MemberCenter.Infrastructure/Persistence/Migrations/20260417043000_ChangeUserAddressDefaultToSinglePerUser.cs new file mode 100644 index 0000000..e1ae5c6 --- /dev/null +++ b/src/MemberCenter.Infrastructure/Persistence/Migrations/20260417043000_ChangeUserAddressDefaultToSinglePerUser.cs @@ -0,0 +1,90 @@ +using MemberCenter.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MemberCenter.Infrastructure.Persistence.Migrations +{ + /// + [DbContext(typeof(MemberCenterDbContext))] + [Migration("20260417043000_ChangeUserAddressDefaultToSinglePerUser")] + public partial class ChangeUserAddressDefaultToSinglePerUser : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(""" + WITH ranked_defaults AS ( + SELECT "Id", + ROW_NUMBER() OVER ( + PARTITION BY "UserId" + ORDER BY "UpdatedAt" DESC, "CreatedAt" DESC, "Id" + ) AS rn + FROM user_addresses + WHERE "IsDefault" = true + ) + UPDATE user_addresses ua + SET "IsDefault" = false, + "UpdatedAt" = now() + FROM ranked_defaults rd + WHERE ua."Id" = rd."Id" + AND rd.rn > 1; + """); + + migrationBuilder.Sql(""" + WITH users_without_default AS ( + SELECT ua."UserId" + FROM user_addresses ua + GROUP BY ua."UserId" + HAVING BOOL_OR(ua."IsDefault") = false + ), + replacement AS ( + SELECT ranked."Id" + FROM ( + SELECT ua."Id", + ua."UserId", + ROW_NUMBER() OVER ( + PARTITION BY ua."UserId" + ORDER BY ua."UpdatedAt" DESC, ua."CreatedAt" DESC, ua."Id" + ) AS rn + FROM user_addresses ua + INNER JOIN users_without_default uwd ON uwd."UserId" = ua."UserId" + ) ranked + WHERE ranked.rn = 1 + ) + UPDATE user_addresses ua + SET "IsDefault" = true, + "UpdatedAt" = now() + FROM replacement r + WHERE ua."Id" = r."Id"; + """); + + migrationBuilder.DropIndex( + name: "ux_user_addresses_default_per_usage", + table: "user_addresses"); + + migrationBuilder.CreateIndex( + name: "ux_user_addresses_default_per_user", + table: "user_addresses", + columns: new[] { "UserId", "IsDefault" }, + unique: true, + filter: "\"IsDefault\" = true"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "ux_user_addresses_default_per_user", + table: "user_addresses"); + + migrationBuilder.CreateIndex( + name: "ux_user_addresses_default_per_usage", + table: "user_addresses", + columns: new[] { "UserId", "Usage", "IsDefault" }, + unique: true, + filter: "\"IsDefault\" = true"); + } + } +} diff --git a/src/MemberCenter.Infrastructure/Persistence/Migrations/MemberCenterDbContextModelSnapshot.cs b/src/MemberCenter.Infrastructure/Persistence/Migrations/MemberCenterDbContextModelSnapshot.cs index 60f576a..bd0af2d 100644 --- a/src/MemberCenter.Infrastructure/Persistence/Migrations/MemberCenterDbContextModelSnapshot.cs +++ b/src/MemberCenter.Infrastructure/Persistence/Migrations/MemberCenterDbContextModelSnapshot.cs @@ -374,10 +374,10 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations b.HasIndex("UserId", "Usage") .HasDatabaseName("idx_user_addresses_user_id_usage"); - b.HasIndex("UserId", "Usage", "IsDefault") + b.HasIndex("UserId", "IsDefault") .IsUnique() - .HasDatabaseName("ux_user_addresses_default_per_usage") - .HasFilter("\"is_default\" = true"); + .HasDatabaseName("ux_user_addresses_default_per_user") + .HasFilter("\"IsDefault\" = true"); b.ToTable("user_addresses", (string)null); }); diff --git a/src/MemberCenter.Infrastructure/Services/ProfileService.cs b/src/MemberCenter.Infrastructure/Services/ProfileService.cs index fe6c0c2..78588a9 100644 --- a/src/MemberCenter.Infrastructure/Services/ProfileService.cs +++ b/src/MemberCenter.Infrastructure/Services/ProfileService.cs @@ -107,10 +107,13 @@ public sealed class ProfileService : IProfileService public async Task SaveAddressAsync(Guid userId, SaveUserAddressRequest request) { + await using var transaction = await _dbContext.Database.BeginTransactionAsync(); + var usage = NormalizeUsage(request.Usage); var address = request.Id.HasValue ? await _dbContext.UserAddresses.FirstOrDefaultAsync(x => x.UserId == userId && x.Id == request.Id.Value) : null; + var wasDefault = address?.IsDefault == true; if (request.Id.HasValue && address is null) { @@ -143,19 +146,33 @@ public sealed class ProfileService : IProfileService address.AddressMetaJson = ParseOptionalJson(request.AddressMetaJson); address.UpdatedAt = DateTimeOffset.UtcNow; - var existingWithUsage = await _dbContext.UserAddresses - .Where(x => x.UserId == userId && x.Usage == usage && x.Id != address.Id) + var otherAddresses = await _dbContext.UserAddresses + .Where(x => x.UserId == userId && x.Id != address.Id) .ToListAsync(); - var shouldBeDefault = request.IsDefault || !existingWithUsage.Any(); - address.IsDefault = shouldBeDefault; + var shouldBeDefault = !otherAddresses.Any() + || request.IsDefault + || (!otherAddresses.Any(x => x.IsDefault) && wasDefault) + || (!otherAddresses.Any(x => x.IsDefault) && !request.Id.HasValue); + + // Persist the address first with a non-default state so the unique index + // never sees two defaults during the switch. + address.IsDefault = false; + await _dbContext.SaveChangesAsync(); + if (shouldBeDefault) { - foreach (var item in existingWithUsage) - { - item.IsDefault = false; - item.UpdatedAt = DateTimeOffset.UtcNow; - } + await _dbContext.UserAddresses + .Where(x => x.UserId == userId && x.Id != address.Id && x.IsDefault) + .ExecuteUpdateAsync(setters => setters + .SetProperty(x => x.IsDefault, false) + .SetProperty(x => x.UpdatedAt, DateTimeOffset.UtcNow)); + + address.IsDefault = true; + } + else + { + address.IsDefault = false; } await _auditLogWriter.WriteAsync("user", userId, request.Id.HasValue ? "address.updated" : "address.created", new @@ -167,11 +184,14 @@ public sealed class ProfileService : IProfileService }); await _dbContext.SaveChangesAsync(); + await transaction.CommitAsync(); return MapAddress(address); } public async Task DeleteAddressAsync(Guid userId, Guid addressId) { + await using var transaction = await _dbContext.Database.BeginTransactionAsync(); + var addresses = await _dbContext.UserAddresses .Where(x => x.UserId == userId) .OrderByDescending(x => x.IsDefault) @@ -189,20 +209,29 @@ public sealed class ProfileService : IProfileService throw new InvalidOperationException("Cannot delete the last address."); } - _dbContext.UserAddresses.Remove(address); - if (address.IsDefault) { var replacement = addresses - .Where(x => x.Id != addressId && string.Equals(x.Usage, address.Usage, StringComparison.OrdinalIgnoreCase)) + .Where(x => x.Id != addressId) .OrderByDescending(x => x.CreatedAt) .FirstOrDefault(); if (replacement is not null) { + _dbContext.UserAddresses.Remove(address); + await _dbContext.SaveChangesAsync(); + replacement.IsDefault = true; replacement.UpdatedAt = DateTimeOffset.UtcNow; } + else + { + _dbContext.UserAddresses.Remove(address); + } + } + else + { + _dbContext.UserAddresses.Remove(address); } await _auditLogWriter.WriteAsync("user", userId, "address.deleted", new @@ -212,6 +241,7 @@ public sealed class ProfileService : IProfileService }); await _dbContext.SaveChangesAsync(); + await transaction.CommitAsync(); } private async Task EnsureProfileAsync(Guid userId) diff --git a/src/MemberCenter.Infrastructure/Services/SecuritySettingsService.cs b/src/MemberCenter.Infrastructure/Services/SecuritySettingsService.cs index 7fb6a77..216bf08 100644 --- a/src/MemberCenter.Infrastructure/Services/SecuritySettingsService.cs +++ b/src/MemberCenter.Infrastructure/Services/SecuritySettingsService.cs @@ -56,8 +56,40 @@ public sealed class SecuritySettingsService : ISecuritySettingsService string.Empty); } + public async Task GetSmtpSettingsAsync() + { + var relayHost = await GetFlagAsync(SmtpRelayHostKey, string.Empty); + var publicBaseUrl = await GetFlagAsync(PublicBaseUrlKey, string.Empty); + var relayPort = await GetFlagAsync(SmtpRelayPortKey, 587); + var useTls = await GetFlagAsync(SmtpUseTlsKey, true); + var useSsl = await GetFlagAsync(SmtpUseSslKey, false); + var timeoutSeconds = await GetFlagAsync(SmtpTimeoutSecondsKey, 15); + var username = await GetFlagAsync(SmtpUsernameKey, string.Empty); + var password = await GetFlagAsync(SmtpPasswordKey, string.Empty); + var senderName = await GetFlagAsync(SenderNameKey, "Member Center"); + var senderEmail = await GetFlagAsync(SenderEmailKey, string.Empty); + return new SmtpSettingsDto( + publicBaseUrl, + relayHost, + relayPort, + useTls, + useSsl, + timeoutSeconds, + username, + password, + !string.IsNullOrWhiteSpace(password), + senderName, + senderEmail); + } + public async Task SaveAsync(SecuritySettingsDto settings, Guid? actorUserId = null) { + var publicBaseUrl = Normalize(settings.PublicBaseUrl); + var relayHost = Normalize(settings.SmtpRelayHost); + var username = Normalize(settings.SmtpUsername); + var senderName = Normalize(settings.SenderName); + var senderEmail = Normalize(settings.SenderEmail); + if (settings.SmtpUseTls && settings.SmtpUseSsl) { throw new InvalidOperationException("SMTP TLS and SSL cannot both be enabled."); @@ -65,33 +97,33 @@ public sealed class SecuritySettingsService : ISecuritySettingsService await SetFlagAsync(AccessTokenKey, settings.AccessTokenMinutes.ToString()); await SetFlagAsync(RefreshTokenKey, settings.RefreshTokenDays.ToString()); - await SetFlagAsync(PublicBaseUrlKey, settings.PublicBaseUrl.Trim()); - await SetFlagAsync(SmtpRelayHostKey, settings.SmtpRelayHost.Trim()); + await SetFlagAsync(PublicBaseUrlKey, publicBaseUrl); + await SetFlagAsync(SmtpRelayHostKey, relayHost); await SetFlagAsync(SmtpRelayPortKey, settings.SmtpRelayPort.ToString()); await SetFlagAsync(SmtpUseTlsKey, settings.SmtpUseTls.ToString()); await SetFlagAsync(SmtpUseSslKey, settings.SmtpUseSsl.ToString()); await SetFlagAsync(SmtpTimeoutSecondsKey, settings.SmtpTimeoutSeconds.ToString()); - await SetFlagAsync(SmtpUsernameKey, settings.SmtpUsername.Trim()); + await SetFlagAsync(SmtpUsernameKey, username); if (!string.IsNullOrWhiteSpace(settings.SmtpPassword)) { await SetFlagAsync(SmtpPasswordKey, settings.SmtpPassword); } - await SetFlagAsync(SenderNameKey, settings.SenderName.Trim()); - await SetFlagAsync(SenderEmailKey, settings.SenderEmail.Trim()); + await SetFlagAsync(SenderNameKey, senderName); + await SetFlagAsync(SenderEmailKey, senderEmail); await _auditLogWriter.WriteAsync("user", actorUserId, "system.security_settings_updated", new { settings.AccessTokenMinutes, settings.RefreshTokenDays, - settings.PublicBaseUrl, - settings.SmtpRelayHost, + PublicBaseUrl = publicBaseUrl, + SmtpRelayHost = relayHost, settings.SmtpRelayPort, settings.SmtpUseTls, settings.SmtpUseSsl, settings.SmtpTimeoutSeconds, - settings.SmtpUsername, - settings.SenderName, - settings.SenderEmail + SmtpUsername = username, + SenderName = senderName, + SenderEmail = senderEmail }); await _dbContext.SaveChangesAsync(); @@ -133,32 +165,6 @@ public sealed class SecuritySettingsService : ISecuritySettingsService return 1; } - private async Task GetSmtpSettingsAsync() - { - var relayHost = await GetFlagAsync(SmtpRelayHostKey, string.Empty); - var publicBaseUrl = await GetFlagAsync(PublicBaseUrlKey, string.Empty); - var relayPort = await GetFlagAsync(SmtpRelayPortKey, 587); - var useTls = await GetFlagAsync(SmtpUseTlsKey, true); - var useSsl = await GetFlagAsync(SmtpUseSslKey, false); - var timeoutSeconds = await GetFlagAsync(SmtpTimeoutSecondsKey, 15); - var username = await GetFlagAsync(SmtpUsernameKey, string.Empty); - var password = await GetFlagAsync(SmtpPasswordKey, string.Empty); - var senderName = await GetFlagAsync(SenderNameKey, "Member Center"); - var senderEmail = await GetFlagAsync(SenderEmailKey, string.Empty); - return new SmtpSettingsDto( - publicBaseUrl, - relayHost, - relayPort, - useTls, - useSsl, - timeoutSeconds, - username, - password, - !string.IsNullOrWhiteSpace(password), - senderName, - senderEmail); - } - private static void ValidateSmtpSettings(SmtpSettingsDto smtp) { if (string.IsNullOrWhiteSpace(smtp.RelayHost)) @@ -225,4 +231,6 @@ public sealed class SecuritySettingsService : ISecuritySettingsService } } + private static string Normalize(string? value) => value?.Trim() ?? string.Empty; + } diff --git a/src/MemberCenter.Infrastructure/Services/SmtpEmailSender.cs b/src/MemberCenter.Infrastructure/Services/SmtpEmailSender.cs index b29cd26..1d78eff 100644 --- a/src/MemberCenter.Infrastructure/Services/SmtpEmailSender.cs +++ b/src/MemberCenter.Infrastructure/Services/SmtpEmailSender.cs @@ -16,7 +16,7 @@ public sealed class SmtpEmailSender : IEmailSender public async Task SendAsync(string toEmail, string subject, string textBody, string? htmlBody = null) { - var settings = await _securitySettingsService.GetAsync(); + var settings = await _securitySettingsService.GetSmtpSettingsAsync(); Validate(settings); using var message = new MailMessage @@ -36,30 +36,30 @@ public sealed class SmtpEmailSender : IEmailSender message.IsBodyHtml = false; } - using var client = new SmtpClient(settings.SmtpRelayHost, settings.SmtpRelayPort) + using var client = new SmtpClient(settings.RelayHost, settings.RelayPort) { - EnableSsl = settings.SmtpUseSsl || settings.SmtpUseTls, + EnableSsl = settings.UseSsl || settings.UseTls, DeliveryMethod = SmtpDeliveryMethod.Network, - Timeout = Math.Max(1000, settings.SmtpTimeoutSeconds * 1000) + Timeout = Math.Max(1000, settings.TimeoutSeconds * 1000) }; - if (!string.IsNullOrWhiteSpace(settings.SmtpUsername)) + if (!string.IsNullOrWhiteSpace(settings.Username)) { - client.Credentials = new NetworkCredential(settings.SmtpUsername, settings.SmtpPassword); + client.Credentials = new NetworkCredential(settings.Username, settings.Password); } await client.SendMailAsync(message); return 1; } - private static void Validate(SecuritySettingsDto settings) + private static void Validate(SmtpSettingsDto settings) { - if (string.IsNullOrWhiteSpace(settings.SmtpRelayHost)) + if (string.IsNullOrWhiteSpace(settings.RelayHost)) { throw new InvalidOperationException("SMTP relay host is empty. Please save SMTP settings first."); } - if (settings.SmtpUseTls && settings.SmtpUseSsl) + if (settings.UseTls && settings.UseSsl) { throw new InvalidOperationException("SMTP TLS and SSL cannot both be enabled."); } diff --git a/src/MemberCenter.Web/Controllers/AccountController.cs b/src/MemberCenter.Web/Controllers/AccountController.cs index 0c5470c..79ce35c 100644 --- a/src/MemberCenter.Web/Controllers/AccountController.cs +++ b/src/MemberCenter.Web/Controllers/AccountController.cs @@ -77,7 +77,7 @@ public class AccountController : Controller return LocalRedirect(model.ReturnUrl); } - return RedirectToAction("Index", "Home"); + return RedirectToAction("Index", "Home", new { area = string.Empty }); } [HttpPost] @@ -144,15 +144,16 @@ public class AccountController : Controller return LocalRedirect(returnUrl); } - return RedirectToAction("Index", "Home"); + return RedirectToAction("Index", "Home", new { area = string.Empty }); } [HttpPost] [Authorize] + [ValidateAntiForgeryToken] public async Task Logout() { await _signInManager.SignOutAsync(); - return RedirectToAction("Index", "Home"); + return LocalRedirect("~/"); } [HttpGet] @@ -174,7 +175,7 @@ public class AccountController : Controller var user = await _userManager.GetUserAsync(User); if (user is null) { - return RedirectToAction(nameof(Login)); + return RedirectToAction(nameof(Login), new { area = string.Empty }); } var result = await _userManager.ChangePasswordAsync(user, model.CurrentPassword, model.NewPassword); @@ -326,7 +327,7 @@ public class AccountController : Controller var user = await _userManager.GetUserAsync(User); if (user is null) { - return RedirectToAction(nameof(Login)); + return RedirectToAction(nameof(Login), new { area = string.Empty }); } if (!user.EmailConfirmed) @@ -335,7 +336,7 @@ public class AccountController : Controller TempData["Result"] = "Verification email sent."; } - return RedirectToAction("Index", "Profile"); + return RedirectToAction("Index", "Profile", new { area = string.Empty }); } private string GetBaseUrl() => $"{Request.Scheme}://{Request.Host}{Request.PathBase}"; diff --git a/src/MemberCenter.Web/Controllers/ProfileController.cs b/src/MemberCenter.Web/Controllers/ProfileController.cs index e7246bd..53a7580 100644 --- a/src/MemberCenter.Web/Controllers/ProfileController.cs +++ b/src/MemberCenter.Web/Controllers/ProfileController.cs @@ -31,11 +31,11 @@ public class ProfileController : Controller var user = await _userManager.GetUserAsync(User); if (user is null) { - return RedirectToAction("Login", "Account"); + return RedirectToAction("Login", "Account", new { area = string.Empty }); } var profile = await _profileService.GetProfileAsync(user.Id); - return View(MapProfile(profile)); + return View(MapProfile(profile, user.EmailConfirmed)); } [HttpPost] @@ -50,7 +50,7 @@ public class ProfileController : Controller var user = await _userManager.GetUserAsync(User); if (user is null) { - return RedirectToAction("Login", "Account"); + return RedirectToAction("Login", "Account", new { area = string.Empty }); } try @@ -71,7 +71,7 @@ public class ProfileController : Controller model.InvoiceTitle, model.Remark)); ViewData["Result"] = "Saved"; - return View(MapProfile(profile)); + return View(MapProfile(profile, user.EmailConfirmed)); } catch (InvalidOperationException ex) { @@ -86,7 +86,7 @@ public class ProfileController : Controller var user = await _userManager.GetUserAsync(User); if (user is null) { - return RedirectToAction("Login", "Account"); + return RedirectToAction("Login", "Account", new { area = string.Empty }); } var addresses = await _profileService.ListAddressesAsync(user.Id); @@ -109,12 +109,12 @@ public class ProfileController : Controller [HttpPost("profile/addresses")] [ValidateAntiForgeryToken] - public async Task SaveAddress(AddressFormViewModel model) + public async Task SaveAddress([Bind(Prefix = "Form")] AddressFormViewModel model) { var user = await _userManager.GetUserAsync(User); if (user is null) { - return RedirectToAction("Login", "Account"); + return RedirectToAction("Login", "Account", new { area = string.Empty }); } if (!ModelState.IsValid) @@ -164,7 +164,7 @@ public class ProfileController : Controller var user = await _userManager.GetUserAsync(User); if (user is null) { - return RedirectToAction("Login", "Account"); + return RedirectToAction("Login", "Account", new { area = string.Empty }); } try @@ -185,7 +185,7 @@ public class ProfileController : Controller var user = await _userManager.GetUserAsync(User); if (user is null) { - return RedirectToAction("Login", "Account"); + return RedirectToAction("Login", "Account", new { area = string.Empty }); } return View(new SubscriptionsPageViewModel @@ -201,17 +201,18 @@ public class ProfileController : Controller var user = await _userManager.GetUserAsync(User); if (user is null) { - return RedirectToAction("Login", "Account"); + return RedirectToAction("Login", "Account", new { area = string.Empty }); } await _newsletterService.UnsubscribeForUserAsync(user.Id, id); return RedirectToAction(nameof(Subscriptions)); } - private static ProfileViewModel MapProfile(UserProfileDto profile) => + private static ProfileViewModel MapProfile(UserProfileDto profile, bool emailConfirmed) => new() { Email = profile.Email, + EmailConfirmed = emailConfirmed, LastName = profile.LastName, FirstName = profile.FirstName, NickName = profile.NickName, diff --git a/src/MemberCenter.Web/Models/Profile/ProfileViewModel.cs b/src/MemberCenter.Web/Models/Profile/ProfileViewModel.cs index ea13c82..17826ce 100644 --- a/src/MemberCenter.Web/Models/Profile/ProfileViewModel.cs +++ b/src/MemberCenter.Web/Models/Profile/ProfileViewModel.cs @@ -49,4 +49,5 @@ public sealed class ProfileViewModel public string? Remark { get; set; } public string Email { get; set; } = string.Empty; + public bool EmailConfirmed { get; set; } } diff --git a/src/MemberCenter.Web/Program.cs b/src/MemberCenter.Web/Program.cs index 871e951..c551d57 100644 --- a/src/MemberCenter.Web/Program.cs +++ b/src/MemberCenter.Web/Program.cs @@ -128,7 +128,10 @@ builder.Services.AddOpenIddict() .UseDbContext(); }); -builder.Services.AddControllersWithViews(); +builder.Services.AddControllersWithViews(options => +{ + options.SuppressImplicitRequiredAttributeForNonNullableReferenceTypes = true; +}); builder.Services.AddHttpContextAccessor(); var app = builder.Build(); @@ -169,6 +172,12 @@ static Task HandleAdminAuthRedirectAsync(RedirectContextLogin +

+ Forgot your password? +

+
diff --git a/src/MemberCenter.Web/Views/Profile/Index.cshtml b/src/MemberCenter.Web/Views/Profile/Index.cshtml index 47aba16..1224847 100644 --- a/src/MemberCenter.Web/Views/Profile/Index.cshtml +++ b/src/MemberCenter.Web/Views/Profile/Index.cshtml @@ -14,6 +14,7 @@ @Html.AntiForgeryToken()

Email: @Model.Email

+

Email verification: @(Model.EmailConfirmed ? "Verified" : "Pending verification")

@@ -50,10 +51,11 @@
-

Change Password

-
- @Html.AntiForgeryToken() - -
-

Manage Addresses

-

Manage Subscriptions

+

Change Password

+@if (!Model.EmailConfirmed) +{ +
+ @Html.AntiForgeryToken() + +
+} diff --git a/src/MemberCenter.Web/Views/Profile/Subscriptions.cshtml b/src/MemberCenter.Web/Views/Profile/Subscriptions.cshtml index 93869b5..32b3d95 100644 --- a/src/MemberCenter.Web/Views/Profile/Subscriptions.cshtml +++ b/src/MemberCenter.Web/Views/Profile/Subscriptions.cshtml @@ -7,6 +7,7 @@ } else { +

Use the unsubscribe button in the last column to stop a subscription immediately.

@@ -32,9 +33,13 @@ else { @Html.AntiForgeryToken() - + } + else + { + Already unsubscribed + } } diff --git a/src/MemberCenter.Web/Views/Shared/_Layout.cshtml b/src/MemberCenter.Web/Views/Shared/_Layout.cshtml index d7f791a..54d39c9 100644 --- a/src/MemberCenter.Web/Views/Shared/_Layout.cshtml +++ b/src/MemberCenter.Web/Views/Shared/_Layout.cshtml @@ -36,6 +36,8 @@ From 02874677becfec51b44c91f84fac8f60af51a543 Mon Sep 17 00:00:00 2001 From: warrenchen Date: Fri, 17 Apr 2026 16:47:20 +0900 Subject: [PATCH 2/2] feat: Refactor newsletter preferences management and update UI components --- docs/UI.md | 6 +- docs/USE_CASES.md | 2 +- .../Controllers/NewsletterController.cs | 56 ------------------- .../Models/Newsletter/PreferencesViewModel.cs | 15 ----- src/MemberCenter.Web/Views/Home/Index.cshtml | 2 +- .../Views/Newsletter/Preferences.cshtml | 11 ---- .../Views/Newsletter/PreferencesResult.cshtml | 2 - .../Views/Shared/_Layout.cshtml | 1 - 8 files changed, 5 insertions(+), 90 deletions(-) delete mode 100644 src/MemberCenter.Web/Models/Newsletter/PreferencesViewModel.cs delete mode 100644 src/MemberCenter.Web/Views/Newsletter/Preferences.cshtml delete mode 100644 src/MemberCenter.Web/Views/Newsletter/PreferencesResult.cshtml diff --git a/docs/UI.md b/docs/UI.md index 2348134..f0fc3bd 100644 --- a/docs/UI.md +++ b/docs/UI.md @@ -6,7 +6,7 @@ - Email 驗證 - 個人資料(基本資料、聯絡方式、公司資訊) - 收貨地址簿 -- 訂閱管理(清單與偏好) +- 訂閱管理(清單與退訂) - 退訂(單一清單) - 連結外站(可選:回到來源站點) @@ -24,7 +24,7 @@ - 登入 / 註冊 / 忘記密碼 / 修改密碼 - Email 驗證頁(可自建或導回會員中心) - 訂閱表單(未登入) -- 訂閱偏好管理(登入後) +- 外站自建訂閱偏好管理(登入後,走 API) - 退訂頁(從 email token 進來) ### 管理者端 @@ -49,7 +49,7 @@ - UC-05 Email 驗證: `/account/verifyemail?email=...&token=...` - UC-07 訂閱確認(double opt-in): `/newsletter/confirm?token=...` - UC-08 取消訂閱(單一清單): `/newsletter/unsubscribe?token=...` -- UC-09 訂閱偏好管理(登入後): `/newsletter/preferences?list_id=...&email=...` +- UC-09 訂閱偏好管理(外站整合 API): `/newsletter/preferences?list_id=...&email=...` - UC-10 會員資料查看 / 編輯: `/profile` - UC-10.1 收貨地址簿管理: `/profile/addresses` - UC-10.2 我的電子報訂閱: `/profile/subscriptions` diff --git a/docs/USE_CASES.md b/docs/USE_CASES.md index bc62f63..47fb05e 100644 --- a/docs/USE_CASES.md +++ b/docs/USE_CASES.md @@ -14,7 +14,7 @@ - UC-06 訂閱電子報(未登入) [API] - UC-07 訂閱確認(double opt-in) [UI] - UC-08 取消訂閱(單一清單) [UI] -- UC-09 訂閱偏好管理(登入後) [API/UI] +- UC-09 訂閱偏好管理(登入後) [API] - UC-10 會員資料查看(Email 為主) [API/UI] ## 管理者端 diff --git a/src/MemberCenter.Web/Controllers/NewsletterController.cs b/src/MemberCenter.Web/Controllers/NewsletterController.cs index 0db72e1..7570d7f 100644 --- a/src/MemberCenter.Web/Controllers/NewsletterController.cs +++ b/src/MemberCenter.Web/Controllers/NewsletterController.cs @@ -1,7 +1,6 @@ using MemberCenter.Application.Abstractions; using MemberCenter.Web.Models.Newsletter; using Microsoft.AspNetCore.Mvc; -using System.Text.Json; namespace MemberCenter.Web.Controllers; @@ -63,59 +62,4 @@ public class NewsletterController : Controller ViewData["Result"] = "Subscription canceled."; return View("UnsubscribeResult"); } - - [HttpGet] - public async Task Preferences([FromQuery(Name = "list_id")] Guid listId, [FromQuery] string email) - { - var subscription = await _newsletterService.GetPreferencesAsync(listId, email); - if (subscription is null) - { - ViewData["Result"] = "Subscription not found."; - return View("PreferencesResult"); - } - - return View(new PreferencesViewModel - { - ListId = listId, - Email = email, - PreferencesJson = subscription.Preferences.GetRawText() - }); - } - - [HttpPost] - public async Task Preferences(PreferencesViewModel model) - { - if (!ModelState.IsValid) - { - return View(model); - } - - Dictionary? preferences = null; - try - { - preferences = JsonSerializer.Deserialize>(model.PreferencesJson); - } - catch - { - ModelState.AddModelError(string.Empty, "Invalid JSON."); - return View(model); - } - - var existing = await _newsletterService.GetPreferencesAsync(model.ListId, model.Email); - if (existing is null) - { - ViewData["Result"] = "Subscription not found."; - return View("PreferencesResult"); - } - - var subscription = await _newsletterService.UpdatePreferencesAsync(model.ListId, model.Email, preferences ?? new Dictionary()); - if (subscription is null) - { - ViewData["Result"] = "Subscription not found."; - return View("PreferencesResult"); - } - - ViewData["Result"] = "Preferences updated."; - return View("PreferencesResult"); - } } diff --git a/src/MemberCenter.Web/Models/Newsletter/PreferencesViewModel.cs b/src/MemberCenter.Web/Models/Newsletter/PreferencesViewModel.cs deleted file mode 100644 index 1275916..0000000 --- a/src/MemberCenter.Web/Models/Newsletter/PreferencesViewModel.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace MemberCenter.Web.Models.Newsletter; - -public sealed class PreferencesViewModel -{ - [Required] - public Guid ListId { get; set; } - - [Required] - [EmailAddress] - public string Email { get; set; } = string.Empty; - - public string PreferencesJson { get; set; } = "{}"; -} diff --git a/src/MemberCenter.Web/Views/Home/Index.cshtml b/src/MemberCenter.Web/Views/Home/Index.cshtml index d52490f..31056f3 100644 --- a/src/MemberCenter.Web/Views/Home/Index.cshtml +++ b/src/MemberCenter.Web/Views/Home/Index.cshtml @@ -1,2 +1,2 @@

Member Center

-

Use this portal for account access and newsletter preferences.

+

Use this portal for account access, profile management, addresses, and subscriptions.

diff --git a/src/MemberCenter.Web/Views/Newsletter/Preferences.cshtml b/src/MemberCenter.Web/Views/Newsletter/Preferences.cshtml deleted file mode 100644 index 3946aa0..0000000 --- a/src/MemberCenter.Web/Views/Newsletter/Preferences.cshtml +++ /dev/null @@ -1,11 +0,0 @@ -@model MemberCenter.Web.Models.Newsletter.PreferencesViewModel - -

Preferences

-
- - - - - - - diff --git a/src/MemberCenter.Web/Views/Newsletter/PreferencesResult.cshtml b/src/MemberCenter.Web/Views/Newsletter/PreferencesResult.cshtml deleted file mode 100644 index a6b49ce..0000000 --- a/src/MemberCenter.Web/Views/Newsletter/PreferencesResult.cshtml +++ /dev/null @@ -1,2 +0,0 @@ -

Preferences

-

@(ViewData["Result"] ?? "Done")

diff --git a/src/MemberCenter.Web/Views/Shared/_Layout.cshtml b/src/MemberCenter.Web/Views/Shared/_Layout.cshtml index 54d39c9..42d009b 100644 --- a/src/MemberCenter.Web/Views/Shared/_Layout.cshtml +++ b/src/MemberCenter.Web/Views/Shared/_Layout.cshtml @@ -38,7 +38,6 @@ Profile Addresses Subscriptions - Preferences @if (User.IsInRole("admin") || User.IsInRole("superuser"))